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:
Thomas
2026-09-17 11:34:55 +00:00
committed by Copybot
parent fc40c1f8a4
commit 3fb3baf03a
31 changed files with 2340 additions and 40 deletions
@@ -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' }),