mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Merge pull request #34605 from overleaf/tm-request-access-redesign
[WEB] Request edit access in the new share modal GitOrigin-RevId: d01cf4d038d5ce07c34deac0714b0cdad02136be
This commit is contained in:
@@ -27,12 +27,43 @@ describe('CollaboratorsController', function () {
|
||||
promises: {
|
||||
removeUserFromProject: sinon.stub().resolves(),
|
||||
setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
|
||||
requestAccess: sinon.stub().resolves({ isNew: true }),
|
||||
declineAccessRequest: sinon
|
||||
.stub()
|
||||
.resolves({ removed: true, privilegeLevel: 'review' }),
|
||||
},
|
||||
createTokenHashPrefix: sinon.stub().returns('abc123'),
|
||||
}
|
||||
ctx.CollaboratorsInviteHandler = {
|
||||
promises: {
|
||||
revokeInviteForUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.projectAccess = {
|
||||
getOwnerId: sinon.stub().returns(ctx.user._id),
|
||||
accessRequestCount: sinon.stub().returns(0),
|
||||
loadAccessRequests: sinon.stub().resolves([]),
|
||||
loadAccessRequestsView: sinon.stub().resolves([]),
|
||||
getAccessRequestForUser: sinon.stub().returns(null),
|
||||
privilegeLevelForUser: sinon.stub().returns('readOnly'),
|
||||
}
|
||||
ctx.CollaboratorsGetter = {
|
||||
promises: {
|
||||
getAllInvitedMembers: sinon.stub(),
|
||||
getProjectAccess: sinon.stub().resolves(ctx.projectAccess),
|
||||
},
|
||||
}
|
||||
ctx.AuthorizationManager = {
|
||||
promises: {
|
||||
getPrivilegeLevelForProject: sinon.stub().resolves('readOnly'),
|
||||
},
|
||||
}
|
||||
ctx.AnalyticsManager = {
|
||||
recordEventForUserInBackground: sinon.stub(),
|
||||
}
|
||||
ctx.EmailHandler = {
|
||||
promises: {
|
||||
sendEmail: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.EditorRealTimeController = {
|
||||
@@ -56,6 +87,10 @@ describe('CollaboratorsController', function () {
|
||||
promises: {
|
||||
getAllInvitedMembers: sinon.stub(),
|
||||
getUser: sinon.stub().resolves(ctx.user),
|
||||
getUsers: sinon.stub().resolves([]),
|
||||
getUserConfirmedEmails: sinon
|
||||
.stub()
|
||||
.resolves([{ email: 'req@example.com' }]),
|
||||
},
|
||||
}
|
||||
ctx.OwnershipTransferHandler = {
|
||||
@@ -108,6 +143,13 @@ describe('CollaboratorsController', function () {
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs',
|
||||
() => ({
|
||||
default: ctx.CollaboratorsInviteHandler,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs',
|
||||
() => ({
|
||||
@@ -176,6 +218,24 @@ describe('CollaboratorsController', function () {
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Authorization/AuthorizationManager.mjs',
|
||||
() => ({
|
||||
default: ctx.AuthorizationManager,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Analytics/AnalyticsManager.mjs',
|
||||
() => ({
|
||||
default: ctx.AnalyticsManager,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
|
||||
default: ctx.EmailHandler,
|
||||
}))
|
||||
|
||||
ctx.CollaboratorsController = (await import(MODULE_PATH)).default
|
||||
})
|
||||
|
||||
@@ -349,6 +409,40 @@ describe('CollaboratorsController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAccessRequests', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.requesterId = new ObjectId()
|
||||
ctx.editAccessRequests = [
|
||||
{
|
||||
_id: ctx.requesterId,
|
||||
email: 'r@e.com',
|
||||
first_name: 'Req',
|
||||
last_name: 'User',
|
||||
privilegeLevel: 'readAndWrite',
|
||||
currentPrivilegeLevel: 'readOnly',
|
||||
requestedAt: new Date(),
|
||||
},
|
||||
]
|
||||
ctx.projectAccess.loadAccessRequestsView = sinon
|
||||
.stub()
|
||||
.resolves(ctx.editAccessRequests)
|
||||
await new Promise(resolve => {
|
||||
ctx.req.params = { Project_id: ctx.projectId.toString() }
|
||||
ctx.res.json = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.getAccessRequests(ctx.req, ctx.res)
|
||||
})
|
||||
})
|
||||
|
||||
it('responds with the flattened owner view of the requests', function (ctx) {
|
||||
expect(
|
||||
ctx.CollaboratorsGetter.promises.getProjectAccess
|
||||
).to.have.been.calledWith(ctx.projectId.toString())
|
||||
expect(ctx.res.json).to.have.been.calledWith({
|
||||
editAccessRequests: ctx.editAccessRequests,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCollaboratorInfo', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = {
|
||||
@@ -551,4 +645,365 @@ describe('CollaboratorsController', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestAccess', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.ownerId = new ObjectId()
|
||||
ctx.req.params = { Project_id: ctx.projectId.toString() }
|
||||
ctx.req.body = { privilegeLevel: 'readAndWrite' }
|
||||
ctx.ProjectGetter.promises.getProject.resolves({
|
||||
_id: ctx.projectId,
|
||||
name: 'My Project',
|
||||
owner_ref: ctx.ownerId,
|
||||
})
|
||||
ctx.UserGetter.promises.getUsers.resolves([
|
||||
{
|
||||
_id: ctx.ownerId,
|
||||
email: 'owner@example.com',
|
||||
first_name: 'O',
|
||||
last_name: 'wner',
|
||||
},
|
||||
{
|
||||
_id: ctx.user._id,
|
||||
email: 'req@example.com',
|
||||
first_name: 'Re',
|
||||
last_name: 'Quester',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects callers who are editors or owners', async function (ctx) {
|
||||
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
|
||||
'readAndWrite'
|
||||
)
|
||||
await new Promise(resolve => {
|
||||
ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.CollaboratorsHandler.promises.requestAccess).to.not.have.been
|
||||
.called
|
||||
})
|
||||
|
||||
it('lets a reviewer request editor access', async function (ctx) {
|
||||
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
|
||||
'review'
|
||||
)
|
||||
ctx.req.body = { privilegeLevel: 'readAndWrite' }
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.requestAccess
|
||||
).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
ctx.user._id,
|
||||
'readAndWrite'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a reviewer asking for the reviewer level they already hold', async function (ctx) {
|
||||
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
|
||||
'review'
|
||||
)
|
||||
ctx.req.body = { privilegeLevel: 'review' }
|
||||
await new Promise(resolve => {
|
||||
ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.CollaboratorsHandler.promises.requestAccess).to.not.have.been
|
||||
.called
|
||||
})
|
||||
|
||||
it('emits + emails + records analytics on a fresh request', async function (ctx) {
|
||||
ctx.CollaboratorsHandler.promises.requestAccess.resolves({
|
||||
isNew: true,
|
||||
})
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res)
|
||||
})
|
||||
ctx.res.sendStatus.calledWith(204).should.equal(true)
|
||||
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
'project:membership:changed',
|
||||
{ accessRequests: true }
|
||||
)
|
||||
expect(
|
||||
ctx.AnalyticsManager.recordEventForUserInBackground
|
||||
).to.have.been.calledWith(ctx.user._id, 'project-access-requested', {
|
||||
projectId: ctx.projectId.toString(),
|
||||
currentPrivilegeLevel: 'readOnly',
|
||||
privilegeLevel: 'readAndWrite',
|
||||
})
|
||||
// notify is async + swallowed via .catch; let microtasks drain
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
|
||||
'accessRequest',
|
||||
sinon.match({ to: 'owner@example.com', privilegeLevel: 'readAndWrite' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips email + analytics when isNew is false', async function (ctx) {
|
||||
ctx.CollaboratorsHandler.promises.requestAccess.resolves({
|
||||
isNew: false,
|
||||
})
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have
|
||||
.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('declineAccessRequest', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.requesterId = new ObjectId()
|
||||
ctx.req.params = {
|
||||
Project_id: ctx.projectId.toString(),
|
||||
user_id: ctx.requesterId.toString(),
|
||||
}
|
||||
ctx.req.body = {}
|
||||
ctx.ProjectGetter.promises.getProject.resolves({
|
||||
_id: ctx.projectId,
|
||||
name: 'My Project',
|
||||
})
|
||||
ctx.UserGetter.promises.getUser.resolves({
|
||||
email: 'req@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('pulls the entry, emits, and skips email when notify is absent', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.declineAccessRequest
|
||||
).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
ctx.requesterId.toString()
|
||||
)
|
||||
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
'project:membership:changed',
|
||||
{ accessRequests: true }
|
||||
)
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
})
|
||||
|
||||
it('records a deny analytics event regardless of notify', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.AnalyticsManager.recordEventForUserInBackground
|
||||
).to.have.been.calledWith(
|
||||
ctx.user._id,
|
||||
'project-access-request-actioned',
|
||||
{
|
||||
projectId: ctx.projectId.toString(),
|
||||
privilegeLevel: 'review',
|
||||
decision: 'deny',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not record an analytics event when nothing was pulled', async function (ctx) {
|
||||
ctx.CollaboratorsHandler.promises.declineAccessRequest.resolves({
|
||||
removed: false,
|
||||
})
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have
|
||||
.been.called
|
||||
})
|
||||
|
||||
it('sends the declined email when notify=true and something was removed', async function (ctx) {
|
||||
ctx.req.body = { notify: true }
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
|
||||
'accessRequestDeclined',
|
||||
sinon.match({ to: 'req@example.com' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the declined email when nothing was actually pulled', async function (ctx) {
|
||||
ctx.req.body = { notify: true }
|
||||
ctx.CollaboratorsHandler.promises.declineAccessRequest.resolves({
|
||||
removed: false,
|
||||
})
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('grantAccessRequest', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.requesterId = new ObjectId()
|
||||
ctx.req.params = {
|
||||
Project_id: ctx.projectId.toString(),
|
||||
user_id: ctx.requesterId.toString(),
|
||||
}
|
||||
ctx.req.body = { privilegeLevel: 'readAndWrite', notify: true }
|
||||
ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns({
|
||||
privilegeLevel: 'readAndWrite',
|
||||
requestedAt: new Date(),
|
||||
})
|
||||
ctx.ProjectGetter.promises.getProject.resolves({
|
||||
_id: ctx.projectId,
|
||||
name: 'My Project',
|
||||
})
|
||||
ctx.UserGetter.promises.getUser.resolves({
|
||||
email: 'req@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to grant when it would exceed the collaborator limit', async function (ctx) {
|
||||
ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves(
|
||||
false
|
||||
)
|
||||
await new Promise(resolve => {
|
||||
ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
|
||||
.not.have.been.called
|
||||
expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have
|
||||
.been.called
|
||||
})
|
||||
|
||||
it('skips the limit check when the requester already holds the target level', async function (ctx) {
|
||||
// idempotent re-grant: already at the requested level, so granting must
|
||||
// not be blocked even when the project is at its collaborator limit
|
||||
ctx.projectAccess.privilegeLevelForUser = sinon
|
||||
.stub()
|
||||
.returns('readAndWrite')
|
||||
ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves(
|
||||
false
|
||||
)
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel
|
||||
).to.not.have.been.called
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
|
||||
).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
ctx.requesterId.toString(),
|
||||
'readAndWrite'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes via setCollaboratorPrivilegeLevel and notifies', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
|
||||
).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
ctx.requesterId.toString(),
|
||||
'readAndWrite'
|
||||
)
|
||||
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
|
||||
ctx.projectId.toString(),
|
||||
'project:membership:changed',
|
||||
{ members: true, invites: true, accessRequests: true }
|
||||
)
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
|
||||
'accessRequestGranted',
|
||||
sinon.match({ to: 'req@example.com', privilegeLevel: 'readAndWrite' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears any pending invite for the granted user', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.UserGetter.promises.getUserConfirmedEmails
|
||||
).to.have.been.calledWith(ctx.requesterId.toString())
|
||||
expect(
|
||||
ctx.CollaboratorsInviteHandler.promises.revokeInviteForUser
|
||||
).to.have.been.calledWith(ctx.projectId.toString(), [
|
||||
{ email: 'req@example.com' },
|
||||
])
|
||||
})
|
||||
|
||||
it('records an accept analytics event with the granted level', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(
|
||||
ctx.AnalyticsManager.recordEventForUserInBackground
|
||||
).to.have.been.calledWith(
|
||||
ctx.user._id,
|
||||
'project-access-request-actioned',
|
||||
{
|
||||
projectId: ctx.projectId.toString(),
|
||||
privilegeLevel: 'readAndWrite',
|
||||
decision: 'accept',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the granted email when there was no pending request', async function (ctx) {
|
||||
ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns(null)
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
})
|
||||
|
||||
it('does not record an analytics event when there was no pending request', async function (ctx) {
|
||||
ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns(null)
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have
|
||||
.been.called
|
||||
})
|
||||
|
||||
it('still records the accept event when notify=false', async function (ctx) {
|
||||
ctx.req.body = { privilegeLevel: 'review', notify: false }
|
||||
await new Promise(resolve => {
|
||||
ctx.res.sendStatus = sinon.spy(() => resolve())
|
||||
ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
expect(
|
||||
ctx.AnalyticsManager.recordEventForUserInBackground
|
||||
).to.have.been.calledWith(
|
||||
ctx.user._id,
|
||||
'project-access-request-actioned',
|
||||
{
|
||||
projectId: ctx.projectId.toString(),
|
||||
privilegeLevel: 'review',
|
||||
decision: 'accept',
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -641,4 +641,149 @@ describe('CollaboratorsGetter', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectAccess access-request helpers', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.requestedAt = new Date()
|
||||
ctx.requesterA = new ObjectId()
|
||||
ctx.requesterB = new ObjectId()
|
||||
ctx.projectWithRequests = {
|
||||
...ctx.project,
|
||||
owner_ref: ctx.ownerRef,
|
||||
editAccessRequests: [
|
||||
{
|
||||
userId: ctx.requesterA,
|
||||
privilegeLevel: 'readAndWrite',
|
||||
requestedAt: ctx.requestedAt,
|
||||
},
|
||||
{
|
||||
userId: ctx.requesterB,
|
||||
privilegeLevel: 'review',
|
||||
requestedAt: ctx.requestedAt,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the count of pending requests', function (ctx) {
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.projectWithRequests
|
||||
)
|
||||
expect(access.accessRequestCount()).to.equal(2)
|
||||
})
|
||||
|
||||
it('treats an empty/missing array as zero requests', function (ctx) {
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess({
|
||||
...ctx.project,
|
||||
owner_ref: ctx.ownerRef,
|
||||
})
|
||||
expect(access.accessRequestCount()).to.equal(0)
|
||||
expect(access.getAccessRequestForUser(ctx.requesterA)).to.be.null
|
||||
})
|
||||
|
||||
it('looks up a single request without firing a user fetch', function (ctx) {
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.projectWithRequests
|
||||
)
|
||||
const result = access.getAccessRequestForUser(ctx.requesterB)
|
||||
expect(result).to.deep.equal({
|
||||
privilegeLevel: 'review',
|
||||
requestedAt: ctx.requestedAt,
|
||||
})
|
||||
expect(ctx.UserGetter.promises.getUsers).to.not.have.been.called
|
||||
})
|
||||
|
||||
it('returns null when the user has no pending request', function (ctx) {
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.projectWithRequests
|
||||
)
|
||||
expect(access.getAccessRequestForUser(ctx.nonMemberRef)).to.be.null
|
||||
expect(access.getAccessRequestForUser(null)).to.be.null
|
||||
})
|
||||
|
||||
it('batches user lookups and threads privilege/requestedAt through', async function (ctx) {
|
||||
const userA = {
|
||||
_id: ctx.requesterA,
|
||||
email: 'a@example.com',
|
||||
first_name: 'A',
|
||||
last_name: 'One',
|
||||
}
|
||||
const userB = {
|
||||
_id: ctx.requesterB,
|
||||
email: 'b@example.com',
|
||||
first_name: 'B',
|
||||
last_name: 'Two',
|
||||
}
|
||||
ctx.UserGetter.promises.getUsers.resolves([userA, userB])
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.projectWithRequests
|
||||
)
|
||||
const loaded = await access.loadAccessRequests()
|
||||
expect(ctx.UserGetter.promises.getUsers).to.have.been.calledOnce
|
||||
expect(loaded).to.have.lengthOf(2)
|
||||
expect(loaded[0]).to.deep.equal({
|
||||
user: userA,
|
||||
privilegeLevel: 'readAndWrite',
|
||||
requestedAt: ctx.requestedAt,
|
||||
})
|
||||
expect(loaded[1]).to.deep.equal({
|
||||
user: userB,
|
||||
privilegeLevel: 'review',
|
||||
requestedAt: ctx.requestedAt,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops requests whose user was deleted in the meantime', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUsers.resolves([
|
||||
{
|
||||
_id: ctx.requesterA,
|
||||
email: 'a@example.com',
|
||||
first_name: 'A',
|
||||
last_name: 'One',
|
||||
},
|
||||
])
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.projectWithRequests
|
||||
)
|
||||
const loaded = await access.loadAccessRequests()
|
||||
expect(loaded).to.have.lengthOf(1)
|
||||
expect(loaded[0].privilegeLevel).to.equal('readAndWrite')
|
||||
})
|
||||
|
||||
it('builds the owner view with each requester’s current privilege level', async function (ctx) {
|
||||
const projectForView = {
|
||||
...ctx.project,
|
||||
owner_ref: ctx.ownerRef,
|
||||
readOnly_refs: [ctx.requesterA],
|
||||
editAccessRequests: [
|
||||
{
|
||||
userId: ctx.requesterA,
|
||||
privilegeLevel: 'readAndWrite',
|
||||
requestedAt: ctx.requestedAt,
|
||||
},
|
||||
],
|
||||
}
|
||||
ctx.UserGetter.promises.getUsers.resolves([
|
||||
{
|
||||
_id: ctx.requesterA,
|
||||
email: 'a@example.com',
|
||||
first_name: 'A',
|
||||
last_name: 'One',
|
||||
},
|
||||
])
|
||||
const access = new ctx.CollaboratorsGetter.ProjectAccess(projectForView)
|
||||
const view = await access.loadAccessRequestsView()
|
||||
expect(view).to.deep.equal([
|
||||
{
|
||||
_id: ctx.requesterA,
|
||||
email: 'a@example.com',
|
||||
first_name: 'A',
|
||||
last_name: 'One',
|
||||
privilegeLevel: 'readAndWrite',
|
||||
currentPrivilegeLevel: 'readOnly',
|
||||
requestedAt: ctx.requestedAt,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('CollaboratorsHandler', function () {
|
||||
readOnly_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
archived: ctx.userId,
|
||||
@@ -452,6 +453,7 @@ describe('CollaboratorsHandler', function () {
|
||||
readOnly_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
archived: ctx.userId,
|
||||
@@ -568,6 +570,15 @@ describe('CollaboratorsHandler', function () {
|
||||
)
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
ctx.ProjectMock.expects('updateMany')
|
||||
.withArgs(
|
||||
{ 'editAccessRequests.userId': ctx.fromUserId },
|
||||
{
|
||||
$pull: { editAccessRequests: { userId: ctx.fromUserId } },
|
||||
}
|
||||
)
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
@@ -610,6 +621,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -618,6 +631,9 @@ describe('CollaboratorsHandler', function () {
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
reviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
$addToSet: { readOnly_refs: ctx.userId },
|
||||
}
|
||||
@@ -640,6 +656,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -649,6 +667,9 @@ describe('CollaboratorsHandler', function () {
|
||||
reviewer_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -679,6 +700,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -689,6 +712,9 @@ describe('CollaboratorsHandler', function () {
|
||||
collaberator_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -722,6 +748,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -732,6 +760,9 @@ describe('CollaboratorsHandler', function () {
|
||||
collaberator_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -754,6 +785,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -765,6 +798,9 @@ describe('CollaboratorsHandler', function () {
|
||||
collaberator_refs: ctx.userId,
|
||||
reviewer_refs: ctx.userId,
|
||||
pendingReviewer_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -787,6 +823,8 @@ describe('CollaboratorsHandler', function () {
|
||||
{ collaberator_refs: ctx.userId },
|
||||
{ readOnly_refs: ctx.userId },
|
||||
{ reviewer_refs: ctx.userId },
|
||||
{ tokenAccessReadOnly_refs: ctx.userId },
|
||||
{ tokenAccessReadAndWrite_refs: ctx.userId },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -798,6 +836,9 @@ describe('CollaboratorsHandler', function () {
|
||||
collaberator_refs: ctx.userId,
|
||||
reviewer_refs: ctx.userId,
|
||||
pendingEditor_refs: ctx.userId,
|
||||
editAccessRequests: { userId: ctx.userId },
|
||||
tokenAccessReadOnly_refs: ctx.userId,
|
||||
tokenAccessReadAndWrite_refs: ctx.userId,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -848,4 +889,124 @@ describe('CollaboratorsHandler', function () {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestAccess', function () {
|
||||
it('rebuilds the array via an aggregation pipeline and reports isNew=true', async function (ctx) {
|
||||
ctx.ProjectMock.expects('findOneAndUpdate')
|
||||
.withArgs(
|
||||
{ _id: ctx.project._id },
|
||||
sinon.match(update => {
|
||||
// pipeline update: [{ $set: { editAccessRequests: { $concatArrays: [...] } } }]
|
||||
const stage = Array.isArray(update) && update[0]?.$set
|
||||
const concat = stage?.editAccessRequests?.$concatArrays
|
||||
const filter = concat?.[0]?.$filter
|
||||
const appended = concat?.[1]?.[0]
|
||||
return (
|
||||
// the entry is stored as an ObjectId (not a session string) so
|
||||
// later $pulls match — compare by value
|
||||
filter?.cond?.$ne?.[1] instanceof ObjectId &&
|
||||
filter.cond.$ne[1].equals(ctx.userId) &&
|
||||
appended &&
|
||||
appended.userId instanceof ObjectId &&
|
||||
appended.userId.equals(ctx.userId) &&
|
||||
appended.privilegeLevel === 'readAndWrite' &&
|
||||
appended.requestedAt instanceof Date
|
||||
)
|
||||
}),
|
||||
sinon.match({ returnDocument: 'before' })
|
||||
)
|
||||
.chain('exec')
|
||||
// before-doc has no entry for this user -> isNew
|
||||
.resolves({ _id: ctx.project._id, editAccessRequests: [] })
|
||||
const result = await ctx.CollaboratorsHandler.promises.requestAccess(
|
||||
ctx.project._id,
|
||||
ctx.userId,
|
||||
'readAndWrite'
|
||||
)
|
||||
expect(result).to.deep.equal({ isNew: true })
|
||||
})
|
||||
|
||||
it('reports isNew=false when the user already had a request', async function (ctx) {
|
||||
ctx.ProjectMock.expects('findOneAndUpdate')
|
||||
.chain('exec')
|
||||
.resolves({
|
||||
_id: ctx.project._id,
|
||||
editAccessRequests: [
|
||||
{ userId: ctx.userId, privilegeLevel: 'readAndWrite' },
|
||||
],
|
||||
})
|
||||
const result = await ctx.CollaboratorsHandler.promises.requestAccess(
|
||||
ctx.project._id,
|
||||
ctx.userId,
|
||||
'review'
|
||||
)
|
||||
expect(result).to.deep.equal({ isNew: false })
|
||||
})
|
||||
|
||||
it('reports isNew=false when the project was not found', async function (ctx) {
|
||||
ctx.ProjectMock.expects('findOneAndUpdate').chain('exec').resolves(null)
|
||||
const result = await ctx.CollaboratorsHandler.promises.requestAccess(
|
||||
ctx.project._id,
|
||||
ctx.userId,
|
||||
'readAndWrite'
|
||||
)
|
||||
expect(result).to.deep.equal({ isNew: false })
|
||||
})
|
||||
|
||||
it('rejects unsupported privilege levels', async function (ctx) {
|
||||
await expect(
|
||||
ctx.CollaboratorsHandler.promises.requestAccess(
|
||||
ctx.project._id,
|
||||
ctx.userId,
|
||||
'readOnly'
|
||||
)
|
||||
).to.be.rejected
|
||||
await expect(
|
||||
ctx.CollaboratorsHandler.promises.requestAccess(
|
||||
ctx.project._id,
|
||||
ctx.userId,
|
||||
'owner'
|
||||
)
|
||||
).to.be.rejected
|
||||
})
|
||||
})
|
||||
|
||||
describe('declineAccessRequest', function () {
|
||||
it('pulls the entry and reports the removed level from the pre-image', async function (ctx) {
|
||||
ctx.ProjectMock.expects('findOneAndUpdate')
|
||||
.withArgs(
|
||||
{ _id: ctx.project._id },
|
||||
{ $pull: { editAccessRequests: { userId: ctx.userId } } },
|
||||
sinon.match({ returnDocument: 'before' })
|
||||
)
|
||||
.chain('exec')
|
||||
.resolves({
|
||||
_id: ctx.project._id,
|
||||
editAccessRequests: [
|
||||
{ userId: ctx.userId, privilegeLevel: 'review' },
|
||||
],
|
||||
})
|
||||
const result =
|
||||
await ctx.CollaboratorsHandler.promises.declineAccessRequest(
|
||||
ctx.project._id,
|
||||
ctx.userId
|
||||
)
|
||||
expect(result).to.deep.equal({ removed: true, privilegeLevel: 'review' })
|
||||
})
|
||||
|
||||
it('reports removed=false when the user had no pending request', async function (ctx) {
|
||||
ctx.ProjectMock.expects('findOneAndUpdate')
|
||||
.chain('exec')
|
||||
.resolves({ _id: ctx.project._id, editAccessRequests: [] })
|
||||
const result =
|
||||
await ctx.CollaboratorsHandler.promises.declineAccessRequest(
|
||||
ctx.project._id,
|
||||
ctx.userId
|
||||
)
|
||||
expect(result).to.deep.equal({
|
||||
removed: false,
|
||||
privilegeLevel: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('EditorHttpController', function () {
|
||||
}
|
||||
const members = ctx.members
|
||||
const ownerMember = ctx.ownerMember
|
||||
const ownerId = ctx.ownerId
|
||||
ctx.CollaboratorsGetter = {
|
||||
ProjectAccess: class {
|
||||
loadOwnerAndInvitedMembers() {
|
||||
@@ -85,6 +86,22 @@ describe('EditorHttpController', function () {
|
||||
isUserInvitedMember() {
|
||||
return false
|
||||
}
|
||||
|
||||
getOwnerId() {
|
||||
return ownerId
|
||||
}
|
||||
|
||||
loadAccessRequestsView() {
|
||||
return []
|
||||
}
|
||||
|
||||
getAccessRequestForUser() {
|
||||
return null
|
||||
}
|
||||
|
||||
privilegeLevelForUser() {
|
||||
return 'readOnly'
|
||||
}
|
||||
},
|
||||
promises: {
|
||||
isUserInvitedMemberOfProject: sinon.stub().resolves(false),
|
||||
@@ -366,6 +383,40 @@ describe('EditorHttpController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a restricted user who has a pending access request', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.accessRequest = {
|
||||
privilegeLevel: 'readAndWrite',
|
||||
requestedAt: new Date(),
|
||||
}
|
||||
sinon
|
||||
.stub(
|
||||
ctx.CollaboratorsGetter.ProjectAccess.prototype,
|
||||
'getAccessRequestForUser'
|
||||
)
|
||||
.returns(ctx.accessRequest)
|
||||
ctx.ProjectEditorHandler.buildProjectModelView.returns(
|
||||
ctx.reducedProjectView
|
||||
)
|
||||
ctx.AuthorizationManager.isRestrictedUser.returns(true)
|
||||
ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
|
||||
'readOnly'
|
||||
)
|
||||
await new Promise(resolve => {
|
||||
ctx.res.callback = resolve
|
||||
ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the caller’s own access request in the project view', function (ctx) {
|
||||
expect(
|
||||
ctx.ProjectEditorHandler.buildProjectModelView
|
||||
).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true, {
|
||||
myAccessRequest: ctx.accessRequest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authorized', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
|
||||
@@ -1219,4 +1219,112 @@ describe('EmailBuilder', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessRequest', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.opts = {
|
||||
to: 'owner@example.com',
|
||||
project: { _id: 'abc123', name: 'My Project' },
|
||||
owner: {
|
||||
email: 'owner@example.com',
|
||||
first_name: 'O',
|
||||
last_name: 'wner',
|
||||
},
|
||||
requester: {
|
||||
email: 'req@example.com',
|
||||
first_name: 'Re',
|
||||
last_name: 'Quester',
|
||||
},
|
||||
privilegeLevel: 'readAndWrite',
|
||||
}
|
||||
})
|
||||
|
||||
it('names the requester and project in the subject', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts)
|
||||
expect(email.subject).to.contain('req@example.com')
|
||||
expect(email.subject).to.contain('My Project')
|
||||
expect(email.subject).to.contain('editor')
|
||||
})
|
||||
|
||||
it('says "reviewer" in the subject when the review level was requested', function (ctx) {
|
||||
ctx.opts.privilegeLevel = 'review'
|
||||
const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts)
|
||||
expect(email.subject).to.contain('reviewer')
|
||||
expect(email.subject).to.not.contain('editor access')
|
||||
})
|
||||
|
||||
it('deep-links the CTA back to the share modal via ?share=1', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts)
|
||||
expect(email.text).to.contain(
|
||||
'https://www.overleaf.com/project/abc123?share=1'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not leave any "undefined" placeholders', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts)
|
||||
expect(email.subject).to.not.contain('undefined')
|
||||
expect(email.html).to.not.contain('undefined')
|
||||
expect(email.text).to.not.contain('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessRequestGranted', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.opts = {
|
||||
to: 'req@example.com',
|
||||
project: { _id: 'abc123', name: 'My Project' },
|
||||
requester: {
|
||||
email: 'req@example.com',
|
||||
first_name: 'Re',
|
||||
last_name: 'Quester',
|
||||
},
|
||||
privilegeLevel: 'readAndWrite',
|
||||
}
|
||||
})
|
||||
|
||||
it('mentions the granted role in the message', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail(
|
||||
'accessRequestGranted',
|
||||
ctx.opts
|
||||
)
|
||||
expect(email.text).to.contain('editor')
|
||||
expect(email.text).to.contain('My Project')
|
||||
})
|
||||
|
||||
it('switches to reviewer wording when the granted level was review', function (ctx) {
|
||||
ctx.opts.privilegeLevel = 'review'
|
||||
const email = ctx.EmailBuilder.buildEmail(
|
||||
'accessRequestGranted',
|
||||
ctx.opts
|
||||
)
|
||||
expect(email.text).to.contain('reviewer')
|
||||
})
|
||||
|
||||
it('points the CTA at the project (no ?share=1, owner does not need it)', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail(
|
||||
'accessRequestGranted',
|
||||
ctx.opts
|
||||
)
|
||||
expect(email.text).to.contain('https://www.overleaf.com/project/abc123')
|
||||
expect(email.text).to.not.contain('share=1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessRequestDeclined', function () {
|
||||
it('is a NoCTA email mentioning the project name', function (ctx) {
|
||||
const email = ctx.EmailBuilder.buildEmail('accessRequestDeclined', {
|
||||
to: 'req@example.com',
|
||||
project: { _id: 'abc123', name: 'My Project' },
|
||||
requester: {
|
||||
email: 'req@example.com',
|
||||
first_name: 'Re',
|
||||
last_name: 'Quester',
|
||||
},
|
||||
})
|
||||
expect(email.subject).to.contain('My Project')
|
||||
expect(email.subject).to.contain('declined')
|
||||
expect(email.text).to.contain('My Project')
|
||||
expect(email.text).to.contain('declined')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -207,6 +207,7 @@ describe('ProjectController', function () {
|
||||
promises: {
|
||||
getAssignment: sinon.stub().resolves({ variant: 'default' }),
|
||||
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
|
||||
featureFlagEnabledForUser: sinon.stub().resolves(false),
|
||||
hasUserBeenAssignedToVariant: sinon.stub().resolves(false),
|
||||
},
|
||||
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
|
||||
|
||||
Reference in New Issue
Block a user