Merge pull request #35312 from overleaf/ii-anonymous-project-access

[web] Restore anonymous read-only access for "Anyone with the link" sharing

GitOrigin-RevId: b17e30259f73d43f0febffeea8908eb47c98b7ee
This commit is contained in:
ilkin-overleaf
2026-09-17 11:25:29 +00:00
committed by Copybot
parent 912fb2dcd8
commit 6fd593ff86
8 changed files with 442 additions and 5 deletions
@@ -1145,5 +1145,162 @@ describe('ProjectInviteTests', function () {
done
)
})
describe('anonymous (logged-out) access', function () {
beforeEach(function (done) {
this.anonymousUser = new User()
this.anonymousUser.getCsrfToken(done)
})
it('grants read-only access via a public sharing link, regardless of the link privilege', function (done) {
Async.series(
[
cb => {
// An "anyone with the link" editor link should still only grant
// anonymous users read-only access.
updateSharingLink(
this.sendingUser,
this.projectId,
'readAndWrite',
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb => expectNoProjectAccess(this.anonymousUser, this.projectId, cb),
cb => {
validateSharingLink(
this.anonymousUser,
this.projectId,
this.sharingLink.token,
(err, response, body) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(200)
expect(body.valid).to.equal(true)
expect(body.redirect).to.equal(true)
cb()
}
)
},
cb => expectProjectAccess(this.anonymousUser, this.projectId, cb),
],
done
)
})
it('renders the invite page for a public sharing link', function (done) {
Async.series(
[
cb => {
updateSharingLink(
this.sendingUser,
this.projectId,
'readOnly',
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb =>
expectInvitePage(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
cb
),
],
done
)
})
it('redirects to register when there is no public sharing link', function (done) {
tryFollowInviteLink(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
(err, response) => {
if (err) {
return done(err)
}
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register/)
done()
}
)
})
it('does not grant access via a group-restricted sharing link', function (done) {
if (!Features.hasFeature('saas')) {
this.skip()
}
const subscription = new Subscription({
adminId: this.sendingUser._id,
memberIds: [this.sendingUser._id],
groupPlan: true,
planCode: 'group_professional',
paymentProvider: { state: 'active' },
})
Async.series(
[
cb => subscription.ensureExists(cb),
cb => {
updateSharingLink(
this.sendingUser,
this.projectId,
'readOnly',
{ subscriptionId: subscription._id.toString() },
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb => {
validateSharingLink(
this.anonymousUser,
this.projectId,
this.sharingLink.token,
(err, response, body) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(200)
expect(body.valid).to.equal(false)
cb()
}
)
},
cb => expectNoProjectAccess(this.anonymousUser, this.projectId, cb),
cb => {
// Group-restricted links send logged-out users to register.
tryFollowInviteLink(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
(err, response) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register/)
cb()
}
)
},
],
done
)
})
})
})
})
@@ -37,6 +37,12 @@ describe('AuthorizationManager', function () {
ctx.CollaboratorsHandler = {}
ctx.CollaboratorsInviteGetter = {
promises: {
getUsableAnonymousSharingLinkInvite: sinon.stub().resolves(null),
},
}
ctx.User = {
findOne: sinon.stub().returns({ exec: sinon.stub().resolves(null) }),
}
@@ -92,6 +98,13 @@ describe('AuthorizationManager', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter',
() => ({
default: ctx.CollaboratorsInviteGetter,
})
)
vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
default: ctx.ProjectGetter,
}))
@@ -395,6 +408,50 @@ describe('AuthorizationManager', function () {
expect(ctx.result).to.equal(false)
})
})
describe('with no user (anonymous) and a public sharing link', function () {
beforeEach(async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite
.withArgs(ctx.project._id, ctx.token)
.resolves({ _id: new ObjectId(), privileges: 'readAndWrite' })
ctx.result =
await ctx.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
ctx.project._id,
ctx.token
)
})
it('should look up the sharing-link invite by token', function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite.should.have.been.calledWith(
ctx.project._id,
ctx.token
)
})
it('should give read-only access regardless of the link privilege', function (ctx) {
expect(ctx.result).to.equal('readOnly')
})
})
describe('with no user (anonymous) and ignorePublicAccess', function () {
beforeEach(async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite
.withArgs(ctx.project._id, ctx.token)
.resolves({ _id: new ObjectId(), privileges: 'readAndWrite' })
ctx.result =
await ctx.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
ctx.project._id,
ctx.token,
{ ignorePublicAccess: true }
)
})
it('should not grant sharing-link access', function (ctx) {
expect(ctx.result).to.equal(false)
})
})
})
describe('with a public project', function () {
@@ -155,6 +155,10 @@ describe('CollaboratorsInviteController', function () {
},
}
ctx.TokenAccessHandler = {
grantSessionTokenAccess: sinon.stub(),
}
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
@@ -270,6 +274,13 @@ describe('CollaboratorsInviteController', function () {
})
)
vi.doMock(
'../../../../app/src/Features/TokenAccess/TokenAccessHandler.mjs',
() => ({
default: ctx.TokenAccessHandler,
})
)
ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default
ctx.res = new MockResponse(vi)
@@ -1978,6 +1989,62 @@ describe('CollaboratorsInviteController', function () {
})
})
describe('viewSharingLink', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.projectId }
ctx.ProjectGetter.promises.getProject.resolves({ name: 'Project' })
})
it('renders the invite page for a logged-in user', async function (ctx) {
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.renderedTemplate).to.equal('project/invite/show')
})
describe('for a logged-out (anonymous) user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(null)
})
it('renders the invite page when there is a public sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves({
privileges: 'readOnly',
subscriptionId: undefined,
})
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.renderedTemplate).to.equal('project/invite/show')
})
it('redirects to register for a group-restricted sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves({
privileges: 'readOnly',
subscriptionId: new ObjectId().toString(),
})
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.redirectedTo).to.equal('/register')
})
it('redirects to register when there is no sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
null
)
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.redirectedTo).to.equal('/register')
})
})
})
describe('validateSharingLink', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.projectId }
@@ -2018,6 +2085,73 @@ describe('CollaboratorsInviteController', function () {
})
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
describe('for a logged-out (anonymous) user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(null)
})
it('grants read-only access and redirects for a public sharing link', async function (ctx) {
ctx.invite.reusable = true
ctx.invite.privileges = 'readAndWrite'
ctx.invite.subscriptionId = undefined
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(
ctx.TokenAccessHandler.grantSessionTokenAccess
).to.have.been.calledWith(ctx.req, ctx.projectId, ctx.token)
expect(ctx.res.json).toHaveBeenCalledWith({
valid: true,
redirect: true,
})
})
it('returns valid false for a group-restricted sharing link', async function (ctx) {
ctx.invite.reusable = true
ctx.invite.privileges = 'readOnly'
ctx.invite.subscriptionId = new ObjectId().toString()
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(ctx.TokenAccessHandler.grantSessionTokenAccess).to.not.have.been
.called
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
it('returns valid false for a one-time (non-reusable) invite', async function (ctx) {
ctx.invite.reusable = false
ctx.invite.privileges = 'readOnly'
ctx.invite.subscriptionId = undefined
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(ctx.TokenAccessHandler.grantSessionTokenAccess).to.not.have.been
.called
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
})
})
describe('_checkShouldInviteEmail', function () {
@@ -247,4 +247,47 @@ describe('CollaboratorsInviteGetter', function () {
})
})
})
describe('getUsableAnonymousSharingLinkInvite', function () {
beforeEach(function (ctx) {
ctx.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(ctx.fakeInvite),
})
ctx.call = async () => {
return await ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite(
ctx.projectId,
ctx.token
)
}
})
it('returns the invite', async function (ctx) {
const invite = await ctx.call()
expect(invite).to.deep.equal(ctx.fakeInvite)
})
it('queries for an active, non-group reusable invite matching the token hash', async function (ctx) {
await ctx.call()
ctx.ProjectInvite.findOne.should.have.been.calledWith({
projectId: ctx.projectId,
reusable: true,
tokenHmac: ctx.tokenHmac,
subscriptionId: null,
privileges: { $ne: false },
})
})
describe('when no matching invite exists', function () {
beforeEach(function (ctx) {
ctx.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(null),
})
})
it('returns null', async function (ctx) {
const invite = await ctx.call()
expect(invite).to.be.null
})
})
})
})