From f5dbbadf7966d101ba16f575f8b0349d39b2e1d3 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 19 Aug 2025 09:12:57 +0100 Subject: [PATCH] add option to disable link sharing (#27626) * add option to remove link-sharing from backend * restrict make link-sharing in the frontend based on capability * extend e2e project-sharing tests to cover OVERLEAF_DISABLE_LINK_SHARING=true * throw an error when link sharing is disabled in TokenAccessHandler * throw errors when attempting to add users to projects with link sharing disabled * Update server-ce/test/project-sharing.spec.ts Co-authored-by: Jakob Ackermann * add tests for existing access when link sharing is disabled * update tests to specify access restrictions for read-only and read-write link shared projects * [web] block access to legacy public project with link-sharing disabled --------- Co-authored-by: Jakob Ackermann GitOrigin-RevId: 5f194dbcb790e973e427c58a3a4a738a5dd74cb4 --- server-ce/test/helpers/config.ts | 40 + server-ce/test/host-admin.js | 1 + server-ce/test/project-sharing.spec.ts | 118 ++- .../Authorization/AuthorizationManager.js | 5 + .../Collaborators/CollaboratorsController.mjs | 5 + .../src/Features/Project/ProjectController.js | 12 + .../TokenAccess/TokenAccessHandler.js | 11 +- .../web/app/src/infrastructure/Features.js | 2 + services/web/app/src/router.mjs | 62 +- services/web/config/settings.defaults.js | 1 + .../components/link-sharing.tsx | 6 + services/web/frontend/js/utils/meta.ts | 2 +- .../test/acceptance/src/TokenAccessTests.mjs | 102 ++ .../frontend/helpers/editor-providers.tsx | 6 +- .../web/test/frontend/helpers/reset-meta.ts | 6 +- .../AuthorizationManagerTests.js | 23 + .../src/Project/ProjectControllerTests.js | 20 + .../TokenAccess/TokenAccessHandlerTests.js | 878 +++++++++++------- 18 files changed, 908 insertions(+), 392 deletions(-) diff --git a/server-ce/test/helpers/config.ts b/server-ce/test/helpers/config.ts index 0576b93875..35ec41d6db 100644 --- a/server-ce/test/helpers/config.ts +++ b/server-ce/test/helpers/config.ts @@ -68,4 +68,44 @@ export function startWith({ }) } +// Allow reloading the server in other places, e.g. beforeEach hooks. +export async function reloadWith({ + pro = false, + version = 'latest', + vars = {}, + varsFn = () => ({}), + withDataDir = false, + resetData = false, + mongoVersion = '', +}) { + Object.assign(vars, varsFn()) + const cfg = JSON.stringify({ + pro, + version, + vars, + withDataDir, + resetData, + mongoVersion, + }) + if (resetData) { + resetCreatedUsersCache() + resetActivateUserRateLimit() + // no return here, always reconfigure when resetting data + } else if (previousConfigFrontend === cfg) { + return + } + const { previousConfigServer } = await reconfigure({ + pro, + version, + vars, + withDataDir, + resetData, + mongoVersion, + }) + if (previousConfigServer !== cfg) { + await Cypress.session.clearAllSavedSessions() + } + previousConfigFrontend = cfg +} + export { reconfigure } diff --git a/server-ce/test/host-admin.js b/server-ce/test/host-admin.js index 4d55e68d7d..e1357764a6 100644 --- a/server-ce/test/host-admin.js +++ b/server-ce/test/host-admin.js @@ -203,6 +203,7 @@ const allowedVars = Joi.object( 'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS', 'OVERLEAF_ALLOW_PUBLIC_ACCESS', 'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING', + 'OVERLEAF_DISABLE_LINK_SHARING', 'EXTERNAL_AUTH', 'OVERLEAF_SAML_ENTRYPOINT', 'OVERLEAF_SAML_CALLBACK_URL', diff --git a/server-ce/test/project-sharing.spec.ts b/server-ce/test/project-sharing.spec.ts index 0546919644..2a90df7368 100644 --- a/server-ce/test/project-sharing.spec.ts +++ b/server-ce/test/project-sharing.spec.ts @@ -1,5 +1,10 @@ import { v4 as uuid } from 'uuid' -import { isExcludedBySharding, startWith } from './helpers/config' +import { + isExcludedBySharding, + startWith, + reloadWith, + STARTUP_TIMEOUT, +} from './helpers/config' import { ensureUserExists, login } from './helpers/login' import { createProject, @@ -358,5 +363,116 @@ describe('Project Sharing', function () { }) }) }) + + describe('with OVERLEAF_DISABLE_LINK_SHARING=true', () => { + const email = 'collaborator-email@example.com' + ensureUserExists({ email }) + + const invitedEmail = 'invited-email@example.com' + ensureUserExists({ email: invitedEmail }) + + const retainedViewerEmail = 'collaborator-retained-viewer@example.com' + ensureUserExists({ email: retainedViewerEmail }) + + const retainedEditorEmail = 'collaborator-retained-editor@example.com' + ensureUserExists({ email: retainedEditorEmail }) + + // Link-sharing urls have to be created before disabling link sharing. + // We use the `beforeEach` hook to reload the server with link sharing + // disabled **after** the initial setup which happens in the `before` + // block. The `before` hook always runs prior to the `beforeEach` hook. + + // Set up retained access before disabling link sharing + before(function () { + // Set up retained viewer access + login(retainedViewerEmail) + openProjectViaLinkSharingAsUser( + linkSharingReadOnly, + projectName, + retainedViewerEmail + ) + + // Set up retained editor access + login(retainedEditorEmail) + openProjectViaLinkSharingAsUser( + linkSharingReadAndWrite, + projectName, + retainedEditorEmail + ) + }) + + beforeEach(function () { + this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload + + return cy.wrap( + reloadWith({ + pro: true, + vars: { + OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true', + OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true', + OVERLEAF_DISABLE_LINK_SHARING: 'true', + }, + withDataDir: true, + }), + { timeout: STARTUP_TIMEOUT } + ) + }) + + it('should not display link sharing in the sharing modal', () => { + login('user@example.com') + openProjectByName(projectName) + cy.findByText('Share').click() + cy.findByText('Turn on link sharing').should('not.exist') + }) + + it('should block new access to read-only link shared projects', () => { + login(email) + + // Test read-only link returns 404 + cy.request({ + url: linkSharingReadOnly, + failOnStatusCode: false, + }).then(response => { + expect(response.status).to.eq(404) + }) + }) + + it('should block new access to read-write link shared projects', () => { + login(email) + + // Test read-write link returns 404 + cy.request({ + url: linkSharingReadAndWrite, + failOnStatusCode: false, + }).then(response => { + expect(response.status).to.eq(404) + }) + }) + + it('should continue to allow email sharing', () => { + login('user@example.com') + shareProjectByEmailAndAcceptInviteViaEmail( + projectName, + invitedEmail, + 'Viewer' + ) + expectFullReadOnlyAccess() + expectProjectDashboardEntry() + }) + + it('should retain read-only access when project was joined via link before link sharing was turned off', () => { + login(retainedViewerEmail) + openProjectByName(projectName) + expectRestrictedReadOnlyAccess() + expectProjectDashboardEntry() + }) + + it('should retain read-write access when project was joined via link before link sharing was turned off', () => { + login(retainedEditorEmail) + openProjectByName(projectName) + expectFullReadAndWriteAccess() + expectProjectDashboardEntry() + }) + }) }) }) diff --git a/services/web/app/src/Features/Authorization/AuthorizationManager.js b/services/web/app/src/Features/Authorization/AuthorizationManager.js index 87c957574b..07a556dc09 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationManager.js +++ b/services/web/app/src/Features/Authorization/AuthorizationManager.js @@ -1,5 +1,6 @@ const { callbackify } = require('util') const { ObjectId } = require('mongodb-legacy') +const Features = require('../../infrastructure/Features') const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const ProjectGetter = require('../Project/ProjectGetter') @@ -213,6 +214,10 @@ async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel( publicAccessLevel, opts = {} ) { + if (!Features.hasFeature('link-sharing')) { + // Link sharing disabled globally. + return PrivilegeLevels.NONE + } if (!opts.ignorePublicAccess) { if (publicAccessLevel === PublicAccessLevels.READ_ONLY) { // Legacy public read-only access for anonymous user diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index d2cecbcfad..cd5b586ea8 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -14,6 +14,7 @@ import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js' import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js' import LimitationsManager from '../Subscription/LimitationsManager.js' +import Features from '../../infrastructure/Features.js' const ObjectId = mongodb.ObjectId @@ -159,6 +160,10 @@ async function getShareTokens(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) + if (!Features.hasFeature('link-sharing')) { + return res.sendStatus(403) // return Forbidden if link sharing is not enabled + } + let tokens if (userId) { tokens = await CollaboratorsGetter.promises.getPublicShareTokens( diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index d8b6b61405..24c28255f8 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -107,6 +107,9 @@ const _ProjectController = { async updateProjectAdminSettings(req, res) { const projectId = req.params.Project_id const user = SessionManager.getSessionUser(req.session) + if (!Features.hasFeature('link-sharing')) { + return res.sendStatus(403) // return Forbidden if link sharing is not enabled + } const publicAccessLevel = req.body.publicAccessLevel const publicAccessLevels = [ PublicAccessLevels.READ_ONLY, @@ -694,6 +697,15 @@ const _ProjectController = { capabilities.push('chat') } + // Note: this is not part of the default capabilities in the backend. + // See services/web/modules/group-settings/app/src/DefaultGroupPolicy.mjs. + // We are only using it on the frontend at the moment. + // Add !Features.hasFeature('saas') to the conditional, as for chat above + // if you define the capability in the backend. + if (Features.hasFeature('link-sharing')) { + capabilities.push('link-sharing') + } + const isOverleafAssistBundleEnabled = splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled' diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js index 0d08903ec3..945bf96a74 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js +++ b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js @@ -9,7 +9,7 @@ const V1Api = require('../V1/V1Api') const crypto = require('crypto') const { callbackifyAll } = require('@overleaf/promise-utils') const Analytics = require('../Analytics/AnalyticsManager') - +const Features = require('../../infrastructure/Features') const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})' const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})' @@ -152,6 +152,9 @@ const TokenAccessHandler = { }, async addReadOnlyUserToProject(userId, projectId, ownerId) { + if (!Features.hasFeature('link-sharing')) { + throw new Error('link sharing is disabled') + } userId = new ObjectId(userId.toString()) projectId = new ObjectId(projectId.toString()) Analytics.recordEventForUserInBackground(userId, 'project-joined', { @@ -202,6 +205,9 @@ const TokenAccessHandler = { }, grantSessionTokenAccess(req, projectId, token) { + if (!Features.hasFeature('link-sharing')) { + throw new Error('link sharing is disabled') + } if (!req.session) { return } @@ -213,6 +219,7 @@ const TokenAccessHandler = { getRequestToken(req, projectId) { const token = + Features.hasFeature('link-sharing') && req.session && req.session.anonTokenAccess && req.session.anonTokenAccess[projectId.toString()] @@ -220,7 +227,7 @@ const TokenAccessHandler = { }, async validateTokenForAnonymousAccess(projectId, token, callback) { - if (!token) { + if (!Features.hasFeature('link-sharing') || !token) { return { isValidReadAndWrite: false, isValidReadOnly: false } } diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index 6147e70e0f..429554da1a 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -69,6 +69,8 @@ const Features = { return Boolean(Settings.overleaf) case 'chat': return Boolean(Settings.disableChat) === false + case 'link-sharing': + return Boolean(Settings.disableLinkSharing) === false case 'github-sync': return Boolean(Settings.enableGithubSync) case 'git-bridge': diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 54cc0f81e1..9c747cbe2d 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -1226,39 +1226,41 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { res.sendStatus(204) }) - webRouter.get( - `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`, - RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken), - AnalyticsRegistrationSourceMiddleware.setSource( - 'collaboration', - 'link-sharing' - ), - TokenAccessController.tokenAccessPage, - AnalyticsRegistrationSourceMiddleware.clearSource() - ) + if (Features.hasFeature('link-sharing')) { + webRouter.get( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) - webRouter.get( - `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`, - RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken), - AnalyticsRegistrationSourceMiddleware.setSource( - 'collaboration', - 'link-sharing' - ), - TokenAccessController.tokenAccessPage, - AnalyticsRegistrationSourceMiddleware.clearSource() - ) + webRouter.get( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) - webRouter.post( - `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`, - RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite), - TokenAccessController.grantTokenAccessReadAndWrite - ) + webRouter.post( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite), + TokenAccessController.grantTokenAccessReadAndWrite + ) - webRouter.post( - `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`, - RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly), - TokenAccessController.grantTokenAccessReadOnly - ) + webRouter.post( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly), + TokenAccessController.grantTokenAccessReadOnly + ) + } webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 701df97244..b52aa986dc 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -432,6 +432,7 @@ module.exports = { ], disableChat: process.env.OVERLEAF_DISABLE_CHAT === 'true', + disableLinkSharing: process.env.OVERLEAF_DISABLE_LINK_SHARING === 'true', enableSubscriptions: false, restrictedCountries: [], enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true', diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index e29f5655a0..7f73c83bd6 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -30,6 +30,8 @@ type AccessLevel = 'private' | 'tokenBased' | 'readAndWrite' | 'readOnly' export default function LinkSharing() { const [inflight, setInflight] = useState(false) const [showLinks, setShowLinks] = useState(true) + const linkSharingEnabled = + getMeta('ol-capabilities')?.includes('link-sharing') const { monitorRequest } = useShareProjectContext() @@ -58,6 +60,10 @@ export default function LinkSharing() { [monitorRequest, projectId] ) + if (!linkSharingEnabled) { + return null + } + switch (publicAccessLevel) { // Private (with token-access available) case 'private': diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 12f46b451c..beb6b0ef93 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -93,7 +93,7 @@ export interface Meta { 'ol-cannot-link-other-third-party-sso': boolean 'ol-cannot-reactivate-subscription': boolean 'ol-cannot-use-ai': boolean - 'ol-capabilities': Array<'dropbox' | 'chat' | 'use-ai'> + 'ol-capabilities': Array<'dropbox' | 'chat' | 'use-ai' | 'link-sharing'> 'ol-compileSettings': { reducedTimeoutWarning: string compileTimeout: number diff --git a/services/web/test/acceptance/src/TokenAccessTests.mjs b/services/web/test/acceptance/src/TokenAccessTests.mjs index 41bcc0c3af..1ffba5dfd5 100644 --- a/services/web/test/acceptance/src/TokenAccessTests.mjs +++ b/services/web/test/acceptance/src/TokenAccessTests.mjs @@ -5,6 +5,8 @@ import request from './helpers/request.js' import settings from '@overleaf/settings' import { db } from '../../../app/src/infrastructure/mongodb.js' import expectErrorResponse from './helpers/expectErrorResponse.mjs' +import logger from '@overleaf/logger' +import sinon from 'sinon' const tryEditorAccess = (user, projectId, test, callback) => async.series( @@ -810,6 +812,106 @@ describe('TokenAccess', function () { }) }) }) + + describe('link sharing disabled', function () { + const previous = settings.disableLinkSharing + let loggerStub + beforeEach(function () { + settings.disableLinkSharing = true + loggerStub = sinon.spy(logger, 'error') + }) + afterEach(function () { + settings.disableLinkSharing = previous + loggerStub.restore() + }) + + it('should deny access to project', function (done) { + async.series( + [ + cb => + tryEditorAccess( + this.anon, + this.projectId, + expectErrorResponse.restricted.html, + cb + ), + // should not allow the user to access read-only token + cb => + tryReadOnlyTokenAccess( + this.anon, + this.tokens.readOnly, + (response, body) => { + // NOTE: This would be 404 when recreating the router. The Server Pro E2E tests cover this. + expect(response.statusCode).to.equal(200) + }, + (response, body) => { + // NOTE: This would be 404 when recreating the router. The Server Pro E2E tests cover this. + expect(response.statusCode).to.equal(500) + expect(loggerStub).to.have.been.calledWithMatch( + { + err: { message: 'link sharing is disabled' }, + }, + '%s %s', + 'POST', + `/read/${this.tokens.readOnly}/grant` + ) + }, + cb + ), + // still no access + cb => + tryEditorAccess( + this.anon, + this.projectId, + expectErrorResponse.restricted.html, + cb + ), + // should not allow the user to join the project + cb => + tryAnonContentAccess( + this.anon, + this.projectId, + this.tokens.readOnly, + (response, body) => { + expect(response.statusCode).to.equal(403) + expect(body).to.equal('Forbidden') + }, + cb + ), + ], + done + ) + }) + + it('should deny access to access tokens', function (done) { + tryFetchProjectTokens(this.anon, this.projectId, (error, response) => { + expect(error).to.equal(null) + expect(response.statusCode).to.equal(403) + done() + }) + }) + + it('should deny access to legacy public project', function (done) { + async.series( + [ + cb => this.owner.makePublic(this.projectId, 'readOnly', cb), + + cb => + tryAnonContentAccess( + this.anon, + this.projectId, + this.tokens.readOnly, + (response, body) => { + expect(response.statusCode).to.equal(403) + expect(body).to.equal('Forbidden') + }, + cb + ), + ], + done + ) + }) + }) }) describe('anonymous read-and-write token, disabled (feature is deprecated)', function () { diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index e3ac0042d6..21a8624792 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -182,7 +182,11 @@ export function EditorProviders({ merge({}, defaultUserSettings, userSettings) ) - window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox']) + window.metaAttributesCache.set('ol-capabilities', [ + 'chat', + 'dropbox', + 'link-sharing', + ]) const scope = merge( { diff --git a/services/web/test/frontend/helpers/reset-meta.ts b/services/web/test/frontend/helpers/reset-meta.ts index e59e62342d..b64eddf396 100644 --- a/services/web/test/frontend/helpers/reset-meta.ts +++ b/services/web/test/frontend/helpers/reset-meta.ts @@ -2,7 +2,11 @@ export function resetMeta() { window.metaAttributesCache = new Map() window.metaAttributesCache.set('ol-projectHistoryBlobsEnabled', true) window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' }) - window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox']) + window.metaAttributesCache.set('ol-capabilities', [ + 'chat', + 'dropbox', + 'link-sharing', + ]) window.metaAttributesCache.set('ol-ExposedSettings', { appName: 'Overleaf', maxEntitiesPerProject: 10, diff --git a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js index 8370ba9bdc..cd99af3e29 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js @@ -63,6 +63,7 @@ describe('AuthorizationManager', function () { passwordStrengthOptions: {}, adminPrivilegeAvailable: true, adminRolesEnabled: false, + moduleImportSequence: [], } this.AuthorizationManager = SandboxedModule.require(modulePath, { requires: { @@ -448,6 +449,28 @@ describe('AuthorizationManager', function () { expect(this.result).to.equal('readAndWrite') }) }) + + describe('with link-sharing disabled', function () { + beforeEach(async function () { + this.settings.disableLinkSharing = true + this.result = + await this.AuthorizationManager.promises.getPrivilegeLevelForProject( + null, + this.project._id, + this.token + ) + }) + + it('should not call CollaboratorsGetter.getProjectAccess', function () { + this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal( + false + ) + }) + + it('should return false', function () { + expect(this.result).to.equal(false) + }) + }) }) describe("when the project doesn't exist", function () { diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index cea42f1506..1898302202 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -382,6 +382,7 @@ describe('ProjectController', function () { describe('updateProjectAdminSettings', function () { it('should update the public access level', function (done) { + this.Features.hasFeature.withArgs('link-sharing').returns(true) this.EditorController.promises.setPublicAccessLevel = sinon .stub() .resolves() @@ -399,6 +400,7 @@ describe('ProjectController', function () { }) it('should record the change in the project audit log', function (done) { + this.Features.hasFeature.withArgs('link-sharing').returns(true) this.EditorController.promises.setPublicAccessLevel = sinon .stub() .resolves() @@ -422,6 +424,24 @@ describe('ProjectController', function () { } this.ProjectController.updateProjectAdminSettings(this.req, this.res) }) + + it('should refuse to update the public access level when link sharing is disabled', function (done) { + this.Features.hasFeature.withArgs('link-sharing').returns(false) + this.EditorController.promises.setPublicAccessLevel = sinon + .stub() + .resolves() + this.req.body = { + publicAccessLevel: 'readOnly', + } + this.res.sendStatus = code => { + this.EditorController.promises.setPublicAccessLevel.called.should.equal( + false + ) + code.should.equal(403) // Forbidden + done() + } + this.ProjectController.updateProjectAdminSettings(this.req, this.res) + }) }) describe('deleteProject', function () { diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js index 52ed31d0a1..4843096327 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js +++ b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js @@ -25,7 +25,7 @@ describe('TokenAccessHandler', function () { 'mongodb-legacy': { ObjectId }, '../../models/Project': { Project: (this.Project = {}) }, '@overleaf/metrics': (this.Metrics = { inc: sinon.stub() }), - '@overleaf/settings': (this.settings = {}), + '@overleaf/settings': (this.settings = { disableLinkSharing: false }), '../V1/V1Api': (this.V1Api = { promises: { request: sinon.stub(), @@ -35,388 +35,211 @@ describe('TokenAccessHandler', function () { '../Analytics/AnalyticsManager': (this.Analytics = { recordEventForUserInBackground: sinon.stub(), }), + '../../infrastructure/Features': (this.Features = {}), }, }) }) - describe('getTokenType', function () { - it('should determine tokens correctly', function () { - const specs = { - abcdefabcdef: 'readOnly', - aaaaaabbbbbb: 'readOnly', - '54325aaaaaa': 'readAndWrite', - '54325aaaaaabbbbbb': 'readAndWrite', - '': null, - abc123def: null, - } - for (const token of Object.keys(specs)) { - expect(this.TokenAccessHandler.getTokenType(token)).to.equal( - specs[token] - ) - } - }) - }) - - describe('getProjectByReadOnlyToken', function () { + describe('when link sharing is enabled', function () { beforeEach(function () { - this.token = 'abcdefabcdef' - this.Project.findOne = sinon.stub().returns({ - exec: sinon.stub().resolves(this.project), - }) + this.Features.hasFeature = sinon + .stub() + .withArgs('link-sharing') + .returns(true) }) - - it('should get the project', async function () { - const project = - await this.TokenAccessHandler.promises.getProjectByReadOnlyToken( - this.token - ) - expect(project).to.exist - expect(this.Project.findOne.callCount).to.equal(1) - }) - }) - - describe('getProjectByReadAndWriteToken', function () { - beforeEach(function () { - sinon.spy(this.Crypto, 'timingSafeEqual') - this.token = '1234abcdefabcdef' - this.project.tokens = { - readAndWrite: this.token, - readAndWritePrefix: '1234', - } - this.Project.findOne = sinon.stub().returns({ - exec: sinon.stub().resolves(this.project), - }) - }) - - afterEach(function () { - this.Crypto.timingSafeEqual.restore() - }) - - it('should get the project and do timing-safe comparison', async function () { - const project = - await this.TokenAccessHandler.promises.getProjectByReadAndWriteToken( - this.token - ) - expect(project).to.exist - expect(this.Crypto.timingSafeEqual.callCount).to.equal(1) - expect( - this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token)) - ).to.equal(true) - expect(this.Project.findOne.callCount).to.equal(1) - }) - }) - - describe('addReadOnlyUserToProject', function () { - beforeEach(function () { - this.Project.updateOne = sinon.stub().returns({ - exec: sinon.stub().resolves(null), - }) - }) - - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.addReadOnlyUserToProject( - this.userId, - this.projectId, - this.project.owner_ref - ) - expect(this.Project.updateOne.callCount).to.equal(1) - expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, - }) - ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( - 'tokenAccessReadOnly_refs' - ) - sinon.assert.calledWith( - this.Analytics.recordEventForUserInBackground, - this.userId, - 'project-joined', - { - mode: 'view', - role: PrivilegeLevels.READ_ONLY, - projectId: this.projectId.toString(), - ownerId: this.project.owner_ref.toString(), - source: 'link-sharing', + describe('getTokenType', function () { + it('should determine tokens correctly', function () { + const specs = { + abcdefabcdef: 'readOnly', + aaaaaabbbbbb: 'readOnly', + '54325aaaaaa': 'readAndWrite', + '54325aaaaaabbbbbb': 'readAndWrite', + '': null, + abc123def: null, } - ) + for (const token of Object.keys(specs)) { + expect(this.TokenAccessHandler.getTokenType(token)).to.equal( + specs[token] + ) + } + }) }) - describe('when Project.updateOne produces an error', function () { + describe('getProjectByReadOnlyToken', function () { + beforeEach(function () { + this.token = 'abcdefabcdef' + this.Project.findOne = sinon.stub().returns({ + exec: sinon.stub().resolves(this.project), + }) + }) + + it('should get the project', async function () { + const project = + await this.TokenAccessHandler.promises.getProjectByReadOnlyToken( + this.token + ) + expect(project).to.exist + expect(this.Project.findOne.callCount).to.equal(1) + }) + }) + + describe('getProjectByReadAndWriteToken', function () { + beforeEach(function () { + sinon.spy(this.Crypto, 'timingSafeEqual') + this.token = '1234abcdefabcdef' + this.project.tokens = { + readAndWrite: this.token, + readAndWritePrefix: '1234', + } + this.Project.findOne = sinon.stub().returns({ + exec: sinon.stub().resolves(this.project), + }) + }) + + afterEach(function () { + this.Crypto.timingSafeEqual.restore() + }) + + it('should get the project and do timing-safe comparison', async function () { + const project = + await this.TokenAccessHandler.promises.getProjectByReadAndWriteToken( + this.token + ) + expect(project).to.exist + expect(this.Crypto.timingSafeEqual.callCount).to.equal(1) + expect( + this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token)) + ).to.equal(true) + expect(this.Project.findOne.callCount).to.equal(1) + }) + }) + + describe('addReadOnlyUserToProject', function () { + beforeEach(function () { + this.Project.updateOne = sinon.stub().returns({ + exec: sinon.stub().resolves(null), + }) + }) + + it('should call Project.updateOne', async function () { + await this.TokenAccessHandler.promises.addReadOnlyUserToProject( + this.userId, + this.projectId, + this.project.owner_ref + ) + expect(this.Project.updateOne.callCount).to.equal(1) + expect( + this.Project.updateOne.calledWith({ + _id: this.projectId, + }) + ).to.equal(true) + expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( + 'tokenAccessReadOnly_refs' + ) + sinon.assert.calledWith( + this.Analytics.recordEventForUserInBackground, + this.userId, + 'project-joined', + { + mode: 'view', + role: PrivilegeLevels.READ_ONLY, + projectId: this.projectId.toString(), + ownerId: this.project.owner_ref.toString(), + source: 'link-sharing', + } + ) + }) + + describe('when Project.updateOne produces an error', function () { + beforeEach(function () { + this.Project.updateOne = sinon + .stub() + .returns({ exec: sinon.stub().rejects(new Error('woops')) }) + }) + + it('should be rejected', async function () { + await expect( + this.TokenAccessHandler.promises.addReadOnlyUserToProject( + this.userId, + this.projectId + ) + ).to.be.rejected + }) + }) + }) + + describe('removeReadAndWriteUserFromProject', function () { beforeEach(function () { this.Project.updateOne = sinon .stub() - .returns({ exec: sinon.stub().rejects(new Error('woops')) }) + .returns({ exec: sinon.stub().resolves(null) }) }) - it('should be rejected', async function () { - await expect( - this.TokenAccessHandler.promises.addReadOnlyUserToProject( - this.userId, - this.projectId - ) - ).to.be.rejected + it('should call Project.updateOne', async function () { + await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject( + this.userId, + this.projectId + ) + + expect(this.Project.updateOne.callCount).to.equal(1) + expect( + this.Project.updateOne.calledWith({ + _id: this.projectId, + }) + ).to.equal(true) + expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + 'tokenAccessReadAndWrite_refs' + ) }) }) - }) - describe('removeReadAndWriteUserFromProject', function () { - beforeEach(function () { - this.Project.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves(null) }) - }) - - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject( - this.userId, - this.projectId - ) - - expect(this.Project.updateOne.callCount).to.equal(1) - expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, - }) - ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( - 'tokenAccessReadAndWrite_refs' - ) - }) - }) - - describe('moveReadAndWriteUserToReadOnly', function () { - beforeEach(function () { - this.Project.updateOne = sinon - .stub() - .returns({ exec: sinon.stub().resolves(null) }) - }) - - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( - this.userId, - this.projectId - ) - - expect(this.Project.updateOne.callCount).to.equal(1) - expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, - }) - ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( - 'tokenAccessReadAndWrite_refs' - ) - expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( - 'tokenAccessReadOnly_refs' - ) - }) - }) - - describe('grantSessionTokenAccess', function () { - beforeEach(function () { - this.req = { session: {}, headers: {} } - }) - - it('should add the token to the session', function () { - this.TokenAccessHandler.promises.grantSessionTokenAccess( - this.req, - this.projectId, - this.token - ) - expect( - this.req.session.anonTokenAccess[this.projectId.toString()] - ).to.equal(this.token) - }) - }) - - describe('validateTokenForAnonymousAccess', function () { - describe('when a read-only project is found', function () { + describe('moveReadAndWriteUserToReadOnly', function () { beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly') - this.TokenAccessHandler.promises.getProjectByToken = sinon + this.Project.updateOne = sinon .stub() - .resolves(this.project) + .returns({ exec: sinon.stub().resolves(null) }) }) - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + it('should call Project.updateOne', async function () { + await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( + this.userId, + this.projectId + ) + + expect(this.Project.updateOne.callCount).to.equal(1) + expect( + this.Project.updateOne.calledWith({ + _id: this.projectId, + }) + ).to.equal(true) + expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + 'tokenAccessReadAndWrite_refs' + ) + expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( + 'tokenAccessReadOnly_refs' + ) + }) + }) + + describe('grantSessionTokenAccess', function () { + beforeEach(function () { + this.req = { session: {}, headers: {} } + }) + + it('should add the token to the session', function () { + this.TokenAccessHandler.promises.grantSessionTokenAccess( + this.req, this.projectId, this.token ) - expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount - ).to.equal(1) - }) - - it('should allow read-only access', async function () { - const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect(isValidReadAndWrite).to.equal(false) - expect(isValidReadOnly).to.equal(true) + this.req.session.anonTokenAccess[this.projectId.toString()] + ).to.equal(this.token) }) }) - describe('when a read-and-write project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getTokenType = sinon - .stub() - .returns('readAndWrite') - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .resolves(this.project) - }) - - describe('when Anonymous token access is not enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false - }) - - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount - ).to.equal(1) - }) - - it('should not allow read-and-write access', async function () { - const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect(isValidReadAndWrite).to.equal(false) - expect(isValidReadOnly).to.equal(false) - }) - }) - - describe('when anonymous token access is enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true - }) - - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount - ).to.equal(1) - }) - - it('should allow read-and-write access', async function () { - const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect(isValidReadAndWrite).to.equal(true) - expect(isValidReadOnly).to.equal(false) - }) - }) - }) - - describe('when no project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .resolves(null) - }) - - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount - ).to.equal(1) - }) - - it('should not allow any access', async function () { - const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect(isValidReadAndWrite).to.equal(false) - expect(isValidReadOnly).to.equal(false) - }) - }) - - describe('when findProject produces an error', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .rejects(new Error('woops')) - }) - - it('should try to find projects with both kinds of token', async function () { - await expect( - this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - ).to.be.rejected - - expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount - ).to.equal(1) - }) - - it('should produce an error and not allow access', async function () { - await expect( - this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - ).to.be.rejected - }) - }) - - describe('when project is not set to token-based access', function () { - beforeEach(function () { - this.project.publicAccesLevel = 'private' - }) - - describe('for read-and-write project', function () { - beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon - .stub() - .returns('readAndWrite') - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .resolves(this.project) - }) - - it('should not allow any access', async function () { - const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token - ) - - expect(isValidReadAndWrite).to.equal(false) - expect(isValidReadOnly).to.equal(false) - }) - }) - - describe('for read-only project', function () { + describe('validateTokenForAnonymousAccess', function () { + describe('when a read-only project is found', function () { beforeEach(function () { this.TokenAccessHandler.getTokenType = sinon .stub() @@ -426,6 +249,114 @@ describe('TokenAccessHandler', function () { .resolves(this.project) }) + it('should try to find projects with both kinds of token', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(1) + }) + + it('should allow read-only access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(true) + }) + }) + + describe('when a read-and-write project is found', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.getTokenType = sinon + .stub() + .returns('readAndWrite') + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(this.project) + }) + + describe('when Anonymous token access is not enabled', function () { + beforeEach(function () { + this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false + }) + + it('should try to find projects with both kinds of token', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(1) + }) + + it('should not allow read-and-write access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + + describe('when anonymous token access is enabled', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true + }) + + it('should try to find projects with both kinds of token', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(1) + }) + + it('should allow read-and-write access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(true) + expect(isValidReadOnly).to.equal(false) + }) + }) + }) + + describe('when no project is found', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(null) + }) + + it('should try to find projects with both kinds of token', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(1) + }) + it('should not allow any access', async function () { const { isValidReadAndWrite, isValidReadOnly } = await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( @@ -438,13 +369,248 @@ describe('TokenAccessHandler', function () { }) }) - describe('with nothing', function () { + describe('when findProject produces an error', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .rejects(new Error('woops')) + }) + + it('should try to find projects with both kinds of token', async function () { + await expect( + this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + ).to.be.rejected + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(1) + }) + + it('should produce an error and not allow access', async function () { + await expect( + this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + ).to.be.rejected + }) + }) + + describe('when project is not set to token-based access', function () { + beforeEach(function () { + this.project.publicAccesLevel = 'private' + }) + + describe('for read-and-write project', function () { + beforeEach(function () { + this.TokenAccessHandler.getTokenType = sinon + .stub() + .returns('readAndWrite') + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(this.project) + }) + + it('should not allow any access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + + describe('for read-only project', function () { + beforeEach(function () { + this.TokenAccessHandler.getTokenType = sinon + .stub() + .returns('readOnly') + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(this.project) + }) + + it('should not allow any access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + + describe('with nothing', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(null) + }) + + it('should not allow any access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + }) + }) + }) + + describe('when link sharing is disabled', function () { + beforeEach(function () { + this.Features.hasFeature = sinon + .stub() + .withArgs('link-sharing') + .returns(false) + }) + + describe('addReadOnlyUserToProject', function () { + beforeEach(function () { + this.Project.updateOne = sinon.stub().returns({ + exec: sinon.stub().resolves(null), + }) + }) + + it('should throw an error', async function () { + await expect( + this.TokenAccessHandler.promises.addReadOnlyUserToProject( + this.userId, + this.projectId, + this.project.owner_ref + ) + ).to.be.rejectedWith('link sharing is disabled') + expect(this.Project.updateOne.callCount).to.equal(0) + }) + }) + + describe('grantSessionTokenAccess', function () { + beforeEach(function () { + this.req = { session: {}, headers: {} } + }) + + it('should throw an error', function () { + expect(() => { + this.TokenAccessHandler.promises.grantSessionTokenAccess( + this.req, + this.projectId, + this.token + ) + }).to.throw('link sharing is disabled') + expect(this.req.session.anonTokenAccess).to.be.undefined + }) + }) + + describe('validateTokenForAnonymousAccess', function () { + describe('when a read-only project is found', function () { + beforeEach(function () { + this.TokenAccessHandler.getTokenType = sinon + .stub() + .returns('readOnly') + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(this.project) + }) + + it('should refuse access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + + describe('when a read-and-write project is found', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.getTokenType = sinon + .stub() + .returns('readAndWrite') + this.TokenAccessHandler.promises.getProjectByToken = sinon + .stub() + .resolves(this.project) + }) + + describe('when Anonymous token access is not enabled', function () { + beforeEach(function () { + this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false + }) + + it('should refuse access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + + describe('when anonymous token access is enabled', function () { + beforeEach(function () { + this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true + }) + + it('should not try to find any projects', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(0) + }) + + it('should refuse access', async function () { + const { isValidReadAndWrite, isValidReadOnly } = + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect(isValidReadAndWrite).to.equal(false) + expect(isValidReadOnly).to.equal(false) + }) + }) + }) + + describe('when no project is found', function () { beforeEach(function () { this.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .resolves(null) }) + it('should not try to find any projects ', async function () { + await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + this.projectId, + this.token + ) + + expect( + this.TokenAccessHandler.promises.getProjectByToken.callCount + ).to.equal(0) + }) + it('should not allow any access', async function () { const { isValidReadAndWrite, isValidReadOnly } = await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(