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
@@ -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
})
})
})
})