diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js
index feab60559b..080dcce37f 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js
@@ -42,8 +42,10 @@ module.exports = {
getProjectsUserIsMemberOf,
dangerouslyGetAllProjectsUserIsMemberOf,
isUserInvitedMemberOfProject,
+ isUserInvitedReadWriteMemberOfProject,
getPublicShareTokens,
userIsTokenMember,
+ userIsReadWriteTokenMember,
getAllInvitedMembers,
},
}
@@ -139,6 +141,23 @@ async function isUserInvitedMemberOfProject(userId, projectId) {
return false
}
+async function isUserInvitedReadWriteMemberOfProject(userId, projectId) {
+ if (!userId) {
+ return false
+ }
+ const members = await getMemberIdsWithPrivilegeLevels(projectId)
+ for (const member of members) {
+ if (
+ member.id.toString() === userId.toString() &&
+ member.source !== Sources.TOKEN &&
+ member.privilegeLevel === PrivilegeLevels.READ_AND_WRITE
+ ) {
+ return true
+ }
+ }
+ return false
+}
+
async function getPublicShareTokens(userId, projectId) {
const memberInfo = await Project.findOne(
{
@@ -253,6 +272,21 @@ async function userIsTokenMember(userId, projectId) {
return project != null
}
+async function userIsReadWriteTokenMember(userId, projectId) {
+ userId = new ObjectId(userId.toString())
+ projectId = new ObjectId(projectId.toString())
+ const project = await Project.findOne(
+ {
+ _id: projectId,
+ tokenAccessReadAndWrite_refs: userId,
+ },
+ {
+ _id: 1,
+ }
+ ).exec()
+ return project != null
+}
+
async function _getInvitedMemberCount(projectId) {
const members = await getMemberIdsWithPrivilegeLevels(projectId)
return members.filter(m => m.source !== Sources.TOKEN).length
diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index f74a953b19..ac8d5563cb 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -467,6 +467,36 @@ const _ProjectController = {
anonRequestToken
)
+ const linkSharingChanges =
+ await SplitTestHandler.promises.getAssignmentForUser(
+ project.owner_ref,
+ 'link-sharing-warning'
+ )
+ if (linkSharingChanges?.variant === 'active') {
+ if (isTokenMember) {
+ // Check explicitly that the user is in read write token refs, while this could be inferred
+ // from the privilege level, the privilege level of token members might later be restricted
+ const isReadWriteTokenMember =
+ await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+ userId,
+ projectId
+ )
+ if (isReadWriteTokenMember) {
+ // Check for an edge case where a user is both in read write token access refs but also
+ // an invited read write member. Ensure they are not redirected to the sharing updates page
+ // We could also delete the token access ref if the user is already a member of the project
+ const isInvitedReadWriteMember =
+ await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+ userId,
+ projectId
+ )
+ if (!isInvitedReadWriteMember) {
+ return res.redirect(`/project/${projectId}/sharing-updates`)
+ }
+ }
+ }
+ }
+
let allowedFreeTrial = true
if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) {
diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.js b/services/web/app/src/Features/TokenAccess/TokenAccessController.js
index 3735c4e6b4..f9914b6c2a 100644
--- a/services/web/app/src/Features/TokenAccess/TokenAccessController.js
+++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.js
@@ -15,6 +15,9 @@ const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
const EditorRealTimeController = require('../Editor/EditorRealTimeController')
+const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
+const ProjectGetter = require('../Project/ProjectGetter')
+const AsyncFormHelper = require('../Helpers/AsyncFormHelper')
const orderedPrivilegeLevels = [
PrivilegeLevels.NONE,
@@ -433,6 +436,116 @@ async function grantTokenAccessReadOnly(req, res, next) {
}
}
+async function ensureUserCanUseSharingUpdatesConsentPage(req, res, next) {
+ const { Project_id: projectId } = req.params
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const project = await ProjectGetter.promises.getProject(projectId, {
+ owner_ref: 1,
+ })
+ if (!project) {
+ throw new Errors.NotFoundError()
+ }
+ const linkSharingChanges =
+ await SplitTestHandler.promises.getAssignmentForUser(
+ project.owner_ref,
+ 'link-sharing-warning'
+ )
+ if (linkSharingChanges?.variant !== 'active') {
+ return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+ }
+ const isReadWriteTokenMember =
+ await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+ userId,
+ projectId
+ )
+ if (!isReadWriteTokenMember) {
+ // If the user is not a read write token member, there are no actions to take
+ return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+ }
+ const isReadWriteMember =
+ await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+ userId,
+ projectId
+ )
+ if (isReadWriteMember) {
+ // If the user is already an invited editor, the actions don't make sense
+ return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
+ }
+ next()
+}
+
+async function sharingUpdatesConsent(req, res, next) {
+ const { Project_id: projectId } = req.params
+ res.render('project/token/sharing-updates', {
+ projectId,
+ })
+}
+
+async function moveReadWriteToCollaborators(req, res, next) {
+ const { Project_id: projectId } = req.params
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const isInvitedMember =
+ await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
+ userId,
+ projectId
+ )
+ await ProjectAuditLogHandler.promises.addEntry(
+ projectId,
+ 'accept-via-link-sharing',
+ userId,
+ req.ip,
+ {
+ privileges: 'readAndWrite',
+ tokenMember: true,
+ invitedMember: isInvitedMember,
+ }
+ )
+ if (isInvitedMember) {
+ // Read only invited viewer who is gaining edit access via link sharing
+ await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+ userId,
+ projectId
+ )
+ await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+ projectId,
+ userId,
+ PrivilegeLevels.READ_AND_WRITE
+ )
+ } else {
+ // Normal case, not invited, joining via link sharing
+ await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+ userId,
+ projectId
+ )
+ await CollaboratorsHandler.promises.addUserIdToProject(
+ projectId,
+ undefined,
+ userId,
+ PrivilegeLevels.READ_AND_WRITE
+ )
+ }
+ EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {
+ members: true,
+ })
+ res.sendStatus(204)
+}
+
+async function moveReadWriteToReadOnly(req, res, next) {
+ const { Project_id: projectId } = req.params
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ await ProjectAuditLogHandler.promises.addEntry(
+ projectId,
+ 'readonly-via-sharing-updates',
+ userId,
+ req.ip
+ )
+ await TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
+ userId,
+ projectId
+ )
+ res.sendStatus(204)
+}
+
module.exports = {
READ_ONLY_TOKEN_PATTERN: TokenAccessHandler.READ_ONLY_TOKEN_PATTERN,
READ_AND_WRITE_TOKEN_PATTERN: TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN,
@@ -440,4 +553,10 @@ module.exports = {
tokenAccessPage: expressify(tokenAccessPage),
grantTokenAccessReadOnly: expressify(grantTokenAccessReadOnly),
grantTokenAccessReadAndWrite: expressify(grantTokenAccessReadAndWrite),
+ ensureUserCanUseSharingUpdatesConsentPage: expressify(
+ ensureUserCanUseSharingUpdatesConsentPage
+ ),
+ sharingUpdatesConsent: expressify(sharingUpdatesConsent),
+ moveReadWriteToCollaborators: expressify(moveReadWriteToCollaborators),
+ moveReadWriteToReadOnly: expressify(moveReadWriteToReadOnly),
}
diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js
index 77b866737c..f5f5ef9728 100644
--- a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js
+++ b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js
@@ -187,6 +187,35 @@ const TokenAccessHandler = {
).exec()
},
+ async removeReadAndWriteUserFromProject(userId, projectId) {
+ userId = new ObjectId(userId.toString())
+ projectId = new ObjectId(projectId.toString())
+
+ return await Project.updateOne(
+ {
+ _id: projectId,
+ },
+ {
+ $pull: { tokenAccessReadAndWrite_refs: userId },
+ }
+ ).exec()
+ },
+
+ async moveReadAndWriteUserToReadOnly(userId, projectId) {
+ userId = new ObjectId(userId.toString())
+ projectId = new ObjectId(projectId.toString())
+
+ return await Project.updateOne(
+ {
+ _id: projectId,
+ },
+ {
+ $pull: { tokenAccessReadAndWrite_refs: userId },
+ $addToSet: { tokenAccessReadOnly_refs: userId },
+ }
+ ).exec()
+ },
+
grantSessionTokenAccess(req, projectId, token) {
if (!req.session) {
return
diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js
new file mode 100644
index 0000000000..54dfa11e0f
--- /dev/null
+++ b/services/web/app/src/Features/TokenAccess/TokenAccessRouter.js
@@ -0,0 +1,31 @@
+const AuthenticationController = require('../Authentication/AuthenticationController')
+const AuthorizationMiddleware = require('../Authorization/AuthorizationMiddleware')
+const TokenAccessController = require('./TokenAccessController')
+
+module.exports = {
+ apply(webRouter) {
+ webRouter.get(
+ `/project/:Project_id/sharing-updates`,
+ AuthenticationController.requireLogin(),
+ AuthorizationMiddleware.ensureUserCanReadProject,
+ TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+ TokenAccessController.sharingUpdatesConsent
+ )
+
+ webRouter.post(
+ `/project/:Project_id/sharing-updates/join`,
+ AuthenticationController.requireLogin(),
+ AuthorizationMiddleware.ensureUserCanReadProject,
+ TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+ TokenAccessController.moveReadWriteToCollaborators
+ )
+
+ webRouter.post(
+ `/project/:Project_id/sharing-updates/view`,
+ AuthenticationController.requireLogin(),
+ AuthorizationMiddleware.ensureUserCanReadProject,
+ TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage,
+ TokenAccessController.moveReadWriteToReadOnly
+ )
+ },
+}
diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js
index 729d8585c1..f1c876d26b 100644
--- a/services/web/app/src/router.js
+++ b/services/web/app/src/router.js
@@ -51,6 +51,7 @@ const BetaProgramController = require('./Features/BetaProgram/BetaProgramControl
const AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
const MetaController = require('./Features/Metadata/MetaController')
const TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
+const TokenAccessRouter = require('./Features/TokenAccess/TokenAccessRouter')
const Features = require('./infrastructure/Features')
const LinkedFilesRouter = require('./Features/LinkedFiles/LinkedFilesRouter')
const TemplatesRouter = require('./Features/Templates/TemplatesRouter')
@@ -270,6 +271,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
TemplatesRouter.apply(webRouter)
UserMembershipRouter.apply(webRouter)
+ TokenAccessRouter.apply(webRouter)
Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug
new file mode 100644
index 0000000000..a0afb0c621
--- /dev/null
+++ b/services/web/app/views/project/token/sharing-updates.pug
@@ -0,0 +1,16 @@
+extends ../../layout-marketing
+
+block entrypointVar
+ - entrypoint = 'pages/sharing-updates'
+
+block vars
+ - var suppressFooter = true
+ - var suppressCookieBanner = true
+ - var suppressSkipToContent = true
+
+block append meta
+ meta(name="ol-project_id" data-type="string" content=projectId)
+
+block content
+ .content.content-alt#main-content
+ div#sharing-updates-page
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 1ef95c39e7..8d998621db 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -874,6 +874,7 @@
"off": "",
"official": "",
"ok": "",
+ "ok_continue_to_project": "",
"ok_join_project": "",
"on": "",
"on_free_plan_upgrade_to_access_features": "",
@@ -1529,6 +1530,7 @@
"update_account_info": "",
"update_dropbox_settings": "",
"update_your_billing_details": "",
+ "updates_to_project_sharing": "",
"updating": "",
"upgrade": "",
"upgrade_cc_btn": "",
@@ -1594,6 +1596,7 @@
"we_sent_new_code": "",
"wed_love_you_to_stay": "",
"welcome_to_sl": "",
+ "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
"were_performing_maintenance": "",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "",
@@ -1640,6 +1643,7 @@
"you_are_on_a_paid_plan_contact_support_to_find_out_more": "",
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
+ "you_can_also_choose_to_view_anonymously_or_leave_the_project": "",
"you_can_now_enable_sso": "",
"you_can_now_log_in_sso": "",
"you_can_only_add_n_people_to_edit_a_project": "",
diff --git a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx
new file mode 100644
index 0000000000..a765972a5e
--- /dev/null
+++ b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx
@@ -0,0 +1,135 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { Trans, useTranslation } from 'react-i18next'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
+import { useCallback } from 'react'
+import getMeta from '@/utils/meta'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import useAsync from '@/shared/hooks/use-async'
+import Notification from '@/shared/components/notification'
+
+function SharingUpdatesRoot() {
+ const { isReady } = useWaitForI18n()
+ const { t } = useTranslation()
+ const { isLoading, isSuccess, isError, runAsync } = useAsync()
+ const projectId = getMeta('ol-project_id')
+
+ const joinProject = useCallback(() => {
+ runAsync(postJSON(`/project/${projectId}/sharing-updates/join`))
+ .then(() => {
+ location.assign(`/project/${projectId}`)
+ })
+ .catch(debugConsole.error)
+ }, [runAsync, projectId])
+
+ const viewProject = useCallback(() => {
+ runAsync(postJSON(`/project/${projectId}/sharing-updates/view`))
+ .then(() => {
+ location.assign(`/project/${projectId}`)
+ })
+ .catch(debugConsole.error)
+ }, [runAsync, projectId])
+
+ const leaveProject = useCallback(() => {
+ runAsync(postJSON(`/project/${projectId}/leave`))
+ .then(() => {
+ location.assign('/project')
+ })
+ .catch(debugConsole.error)
+ }, [runAsync, projectId])
+
+ if (!isReady) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+ {t('updates_to_project_sharing')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {isError && (
+
+ )}
+
+
+
+
+
+ viewProject()}
+ disabled={isLoading || isSuccess}
+ />,
+ // eslint-disable-next-line react/jsx-key
+
+
+
+
+
+
+
+
+ )
+}
+
+export default withErrorBoundary(
+ SharingUpdatesRoot,
+ GenericErrorBoundaryFallback
+)
diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx
new file mode 100644
index 0000000000..d75069129e
--- /dev/null
+++ b/services/web/frontend/js/pages/sharing-updates.tsx
@@ -0,0 +1,11 @@
+import './../utils/meta'
+import './../utils/webpack-public-path'
+import './../infrastructure/error-reporter'
+import './../i18n'
+import ReactDOM from 'react-dom'
+import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root'
+
+const element = document.getElementById('sharing-updates-page')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/frontend/stylesheets/app/invite.less b/services/web/frontend/stylesheets/app/invite.less
index d7f4af2cd7..b0b7752853 100644
--- a/services/web/frontend/stylesheets/app/invite.less
+++ b/services/web/frontend/stylesheets/app/invite.less
@@ -27,3 +27,17 @@
font-size: var(--font-size-07);
line-height: var(--line-height-06);
}
+
+.sharing-updates {
+ font-family: 'Noto Sans', sans-serif;
+ h1 {
+ font-family: 'Noto Sans', sans-serif;
+ // heading-sm
+ font-size: var(--font-size-05);
+ line-height: var(--line-height-04);
+ }
+ small {
+ font-size: var(--font-size-02);
+ line-height: var(--line-height-02);
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index d283bafb13..c9dcbe9206 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1284,6 +1284,7 @@
"off": "Off",
"official": "Official",
"ok": "OK",
+ "ok_continue_to_project": "OK, continue to project",
"ok_join_project": "OK, join project",
"on": "On",
"on_free_plan_upgrade_to_access_features": "You are on the __appName__ Free plan. Upgrade to access these <0>Premium Features0>",
@@ -2151,6 +2152,7 @@
"update_account_info": "Update Account Info",
"update_dropbox_settings": "Update Dropbox Settings",
"update_your_billing_details": "Update Your Billing Details",
+ "updates_to_project_sharing": "Updates to project sharing",
"updating": "Updating",
"updating_site": "Updating Site",
"upgrade": "Upgrade",
@@ -2231,6 +2233,7 @@
"website_status": "Website status",
"wed_love_you_to_stay": "We’d love you to stay",
"welcome_to_sl": "Welcome to __appName__",
+ "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re making some <0>changes to project sharing0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.",
"were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit0> on our free plan, which may have affected this project.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "We’ve recently <0>reduced the compile timeout limit0> on our free plan, which may have affected your project.",
@@ -2292,6 +2295,7 @@
"you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact support0> to find out more.",
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__0> plan as a <1>confirmed member1> of <1>__institutionName__1>",
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__0> plan as a <1>member1> of the group subscription <1>__groupName__1> administered by <1>__adminEmail__1>",
+ "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously0> (you will lose edit access) or <1>leave the project1>.",
"you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
"you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features0>.",
"you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.",
diff --git a/services/web/test/acceptance/src/TokenAccessTests.js b/services/web/test/acceptance/src/TokenAccessTests.js
index 4bf308bfff..65adf25bbb 100644
--- a/services/web/test/acceptance/src/TokenAccessTests.js
+++ b/services/web/test/acceptance/src/TokenAccessTests.js
@@ -5,6 +5,8 @@ const request = require('./helpers/request')
const settings = require('@overleaf/settings')
const { db } = require('../../../app/src/infrastructure/mongodb')
const expectErrorResponse = require('./helpers/expectErrorResponse')
+const SplitTestHandler = require('../../../app/src/Features/SplitTests/SplitTestHandler')
+const sinon = require('sinon')
const tryEditorAccess = (user, projectId, test, callback) =>
async.series(
@@ -237,6 +239,47 @@ const tryFetchProjectTokens = (user, projectId, callback) => {
)
}
+const trySharingUpdatesPage = (user, projectId, test, callback) =>
+ user.request.get(
+ `/project/${projectId}/sharing-updates`,
+ (error, response, body) => {
+ if (error != null) {
+ return callback(error)
+ }
+ test(response, body, projectId)
+ callback()
+ }
+ )
+
+const trySharingUpdatesJoin = (user, projectId, test, callback) =>
+ user.request.post(
+ `/project/${projectId}/sharing-updates/join`,
+ (error, response, body) => {
+ if (error != null) {
+ return callback(error)
+ }
+ test(response, body, projectId)
+ callback()
+ }
+ )
+
+const trySharingUpdatesView = (user, projectId, test, callback) =>
+ user.request.post(
+ `/project/${projectId}/sharing-updates/view`,
+ (error, response, body) => {
+ if (error != null) {
+ return callback(error)
+ }
+ test(response, body, projectId)
+ callback()
+ }
+ )
+
+const expectRedirectToProject = (response, body, projectId) => {
+ expect(response.statusCode).to.equal(302)
+ expect(response.headers.location).to.equal(`/project/${projectId}`)
+}
+
describe('TokenAccess', function () {
beforeEach(function (done) {
this.timeout(90000)
@@ -1782,4 +1825,388 @@ describe('TokenAccess', function () {
)
})
})
+
+ describe('link sharing changes', function () {
+ beforeEach(function () {
+ this.getAssignmentForUser = sinon.stub(
+ SplitTestHandler.promises,
+ 'getAssignmentForUser'
+ )
+ this.getAssignmentForUser.resolves({ variant: 'default' })
+ })
+
+ afterEach(function () {
+ this.getAssignmentForUser.restore()
+ })
+
+ describe('not a member of the project', function () {
+ beforeEach(function (done) {
+ this.projectName = `token-link-sharing-changes${Math.random()}`
+ this.owner.createProject(this.projectName, (err, projectId) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.projectId = projectId
+ this.owner.makeTokenBased(this.projectId, err => {
+ if (err != null) {
+ return done(err)
+ }
+ this.owner.getProject(this.projectId, (err, project) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.tokens = project.tokens
+ done()
+ })
+ })
+ })
+ })
+
+ it('should deny access', function (done) {
+ async.series(
+ [
+ cb => {
+ trySharingUpdatesPage(
+ this.other1,
+ this.projectId,
+ expectErrorResponse.restricted.html,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesJoin(
+ this.other1,
+ this.projectId,
+ expectErrorResponse.restricted.html,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesView(
+ this.other1,
+ this.projectId,
+ expectErrorResponse.restricted.html,
+ cb
+ )
+ },
+ ],
+ done
+ )
+ })
+ })
+
+ describe('read and write token member of project', function () {
+ beforeEach(function (done) {
+ this.projectName = `token-link-sharing-changes${Math.random()}`
+ this.owner.createProject(this.projectName, (err, projectId) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.projectId = projectId
+ this.owner.makeTokenBased(this.projectId, err => {
+ if (err != null) {
+ return done(err)
+ }
+ this.owner.getProject(this.projectId, (err, project) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.tokens = project.tokens
+ // must do token accept before split test enabled
+ // otherwise would be automatically added to named collaborators
+ tryReadAndWriteTokenAccept(
+ this.other1,
+ this.tokens.readAndWrite,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ expect(body.redirect).to.equal(`/project/${this.projectId}`)
+ expect(body.tokenAccessGranted).to.equal('readAndWrite')
+ },
+ done
+ )
+ })
+ })
+ })
+ })
+
+ describe('link sharing changes test not active', function () {
+ it('should redirect to project, same permissions as before', function (done) {
+ async.series(
+ [
+ cb => {
+ trySharingUpdatesPage(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesJoin(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesView(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ tryContentAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(body.privilegeLevel).to.equal('readAndWrite')
+ expect(body.isRestrictedUser).to.equal(false)
+ expect(body.isTokenMember).to.equal(true)
+ expect(body.isInvitedMember).to.equal(false)
+ expect(body.project.owner).to.have.all.keys(
+ '_id',
+ 'email',
+ 'first_name',
+ 'last_name',
+ 'privileges',
+ 'signUpDate'
+ )
+ },
+ cb
+ )
+ },
+ cb => {
+ tryEditorAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ cb
+ )
+ },
+ ],
+ done
+ )
+ })
+ })
+
+ describe('link sharing changes test is active', function () {
+ beforeEach(function () {
+ this.getAssignmentForUser.resolves({ variant: 'active' })
+ })
+ it('should show sharing updates page', function (done) {
+ trySharingUpdatesPage(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ done
+ )
+ })
+
+ it('should allow join to named collaborator', function (done) {
+ async.series(
+ [
+ cb => {
+ trySharingUpdatesJoin(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(204)
+ },
+ cb
+ )
+ },
+ cb => {
+ tryContentAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(body.privilegeLevel).to.equal('readAndWrite')
+ expect(body.isRestrictedUser).to.equal(false)
+ expect(body.isTokenMember).to.equal(false)
+ expect(body.isInvitedMember).to.equal(true) // now collaborator
+ expect(body.project.owner).to.have.all.keys(
+ '_id',
+ 'email',
+ 'first_name',
+ 'last_name',
+ 'privileges',
+ 'signUpDate'
+ )
+ },
+ cb
+ )
+ },
+ cb => {
+ tryEditorAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ cb
+ )
+ },
+ ],
+ done
+ )
+ })
+
+ it('should allow move to anonymous viewer', function (done) {
+ async.series(
+ [
+ cb => {
+ trySharingUpdatesView(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(204)
+ },
+ cb
+ )
+ },
+ cb => {
+ tryContentAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(body.privilegeLevel).to.equal('readOnly')
+ expect(body.isRestrictedUser).to.equal(true)
+ expect(body.isTokenMember).to.equal(true)
+ expect(body.isInvitedMember).to.equal(false)
+ expect(body.project.owner).to.have.keys('_id')
+ },
+ cb
+ )
+ },
+ cb => {
+ tryEditorAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ cb
+ )
+ },
+ ],
+ done
+ )
+ })
+ })
+ })
+
+ describe('read-only token member of project', function () {
+ beforeEach(function (done) {
+ this.projectName = `token-link-sharing-changes${Math.random()}`
+ this.owner.createProject(this.projectName, (err, projectId) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.projectId = projectId
+ this.owner.makeTokenBased(this.projectId, err => {
+ if (err != null) {
+ return done(err)
+ }
+ this.owner.getProject(this.projectId, (err, project) => {
+ if (err != null) {
+ return done(err)
+ }
+ this.tokens = project.tokens
+ tryReadOnlyTokenAccept(
+ this.other1,
+ this.tokens.readOnly,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ expect(body.redirect).to.equal(`/project/${this.projectId}`)
+ expect(body.tokenAccessGranted).to.equal('readOnly')
+ },
+ done
+ )
+ })
+ })
+ })
+ })
+
+ describe('link sharing changes test is active', function () {
+ beforeEach(function () {
+ this.getAssignmentForUser.resolves({ variant: 'active' })
+ })
+
+ it('should redirect to project, same view permissions as before', function (done) {
+ async.series(
+ [
+ cb => {
+ trySharingUpdatesPage(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesJoin(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ trySharingUpdatesView(
+ this.other1,
+ this.projectId,
+ expectRedirectToProject,
+ cb
+ )
+ },
+ cb => {
+ // allow content access read-only
+ tryContentAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(body.privilegeLevel).to.equal('readOnly')
+ expect(body.isRestrictedUser).to.equal(true)
+ expect(body.isTokenMember).to.equal(true)
+ expect(body.isInvitedMember).to.equal(false)
+ expect(body.project.owner).to.have.keys('_id')
+ expect(body.project.owner).to.not.have.any.keys(
+ 'email',
+ 'first_name',
+ 'last_name'
+ )
+ },
+ cb
+ )
+ },
+ cb => {
+ tryEditorAccess(
+ this.other1,
+ this.projectId,
+ (response, body) => {
+ expect(response.statusCode).to.equal(200)
+ },
+ cb
+ )
+ },
+ ],
+ done
+ )
+ })
+ })
+ })
+ })
})
diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js
index 0df201915e..96cb770b19 100644
--- a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js
+++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js
@@ -231,6 +231,38 @@ describe('CollaboratorsGetter', function () {
})
})
+ describe('isUserInvitedReadWriteMemberOfProject', function () {
+ describe('when user is a read write member of the project', function () {
+ it('should return true', async function () {
+ const isMember =
+ await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+ this.readWriteRef1
+ )
+ expect(isMember).to.equal(true)
+ })
+ })
+
+ describe('when user is a read only member of the project', function () {
+ it('should return false', async function () {
+ const isMember =
+ await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+ this.readOnlyRef1
+ )
+ expect(isMember).to.equal(false)
+ })
+ })
+
+ describe('when user is not a member of the project', function () {
+ it('should return false', async function () {
+ const isMember =
+ await this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
+ this.nonMemberRef
+ )
+ expect(isMember).to.equal(false)
+ })
+ })
+ })
+
describe('getProjectsUserIsMemberOf', function () {
beforeEach(function () {
this.fields = 'mock fields'
@@ -365,6 +397,28 @@ describe('CollaboratorsGetter', function () {
})
})
+ describe('userIsReadWriteTokenMember', function () {
+ it('should return true when the project is found', async function () {
+ this.ProjectMock.expects('findOne').chain('exec').resolves(this.project)
+ const isMember =
+ await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+ this.userId,
+ this.project._id
+ )
+ expect(isMember).to.be.true
+ })
+
+ it('should return false when the project is not found', async function () {
+ this.ProjectMock.expects('findOne').chain('exec').resolves(null)
+ const isMember =
+ await this.CollaboratorsGetter.promises.userIsReadWriteTokenMember(
+ this.userId,
+ this.project._id
+ )
+ expect(isMember).to.be.false
+ })
+ })
+
describe('getPublicShareTokens', function () {
const userMock = new ObjectId()
diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js
index c90d512795..6f404e9422 100644
--- a/services/web/test/unit/src/Project/ProjectControllerTests.js
+++ b/services/web/test/unit/src/Project/ProjectControllerTests.js
@@ -127,6 +127,8 @@ describe('ProjectController', function () {
promises: {
userIsTokenMember: sinon.stub().resolves(false),
isUserInvitedMemberOfProject: sinon.stub().resolves(true),
+ userIsReadWriteTokenMember: sinon.stub().resolves(false),
+ isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(true),
},
}
this.ProjectEntityHandler = {}
@@ -1002,6 +1004,53 @@ describe('ProjectController', function () {
this.ProjectController.loadEditor(this.req, this.res)
})
})
+
+ describe('link sharing changes active', function () {
+ beforeEach(function () {
+ this.SplitTestHandler.promises.getAssignmentForUser.resolves({
+ variant: 'active',
+ })
+ })
+
+ describe('when user is a read write token member (and not already a named editor)', function () {
+ beforeEach(function () {
+ this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true)
+ this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+ true
+ )
+ this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+ false
+ )
+ })
+
+ it('should redirect to the sharing-updates page', function (done) {
+ this.res.redirect = url => {
+ expect(url).to.equal(`/project/${this.project_id}/sharing-updates`)
+ done()
+ }
+ this.ProjectController.loadEditor(this.req, this.res)
+ })
+ })
+
+ describe('when user is a read write token member but also a named editor', function () {
+ beforeEach(function () {
+ this.CollaboratorsGetter.promises.userIsTokenMember.resolves(true)
+ this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+ true
+ )
+ this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+ true
+ )
+ })
+
+ it('should not redirect to the sharing-updates page, and should load the editor', function (done) {
+ this.res.render = (pageName, opts) => {
+ done()
+ }
+ this.ProjectController.loadEditor(this.req, this.res)
+ })
+ })
+ })
})
describe('userProjectsJson', function () {
diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js
index 176cfab903..e23c9085c5 100644
--- a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js
+++ b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js
@@ -21,6 +21,7 @@ describe('TokenAccessController', function () {
}
this.req = new MockRequest()
this.res = new MockResponse()
+ this.next = sinon.stub().returns()
this.Settings = {}
this.TokenAccessHandler = {
@@ -40,6 +41,8 @@ describe('TokenAccessController', function () {
getProjectByToken: sinon.stub().resolves(this.project),
getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }),
getV1DocInfo: sinon.stub(),
+ removeReadAndWriteUserFromProject: sinon.stub().resolves(),
+ moveReadAndWriteUserToReadOnly: sinon.stub().resolves(),
},
}
@@ -77,11 +80,26 @@ describe('TokenAccessController', function () {
this.CollaboratorsHandler = {
promises: {
addUserIdToProject: sinon.stub().resolves(),
+ setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
+ },
+ }
+
+ this.CollaboratorsGetter = {
+ promises: {
+ userIsReadWriteTokenMember: sinon.stub().resolves(),
+ isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(),
+ isUserInvitedMemberOfProject: sinon.stub().resolves(),
},
}
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
+ this.ProjectGetter = {
+ promises: {
+ getProject: sinon.stub().resolves(this.project),
+ },
+ }
+
this.TokenAccessController = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
@@ -96,7 +114,12 @@ describe('TokenAccessController', function () {
'../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../Errors/Errors': (this.Errors = { NotFoundError: sinon.stub() }),
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
+ '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
+ '../Project/ProjectGetter': this.ProjectGetter,
+ '../Helpers/AsyncFormHelper': (this.AsyncFormHelper = {
+ redirect: sinon.stub(),
+ }),
},
})
})
@@ -721,4 +744,204 @@ describe('TokenAccessController', function () {
)
})
})
+
+ describe('ensureUserCanUseSharingUpdatesConsentPage', function () {
+ beforeEach(function () {
+ this.req.params = { Project_id: this.project._id }
+ })
+
+ describe('when not in link sharing changes test', function () {
+ beforeEach(function (done) {
+ this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+ this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('redirects to the project/editor', function () {
+ expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+ this.req,
+ this.res,
+ `/project/${this.project._id}`
+ )
+ })
+ })
+
+ describe('when link sharing changes test active', function () {
+ beforeEach(function () {
+ this.SplitTestHandler.promises.getAssignmentForUser.resolves({
+ variant: 'active',
+ })
+ })
+
+ describe('when user is not an invited editor and is a read write token member', function () {
+ beforeEach(function (done) {
+ this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+ false
+ )
+ this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+ true
+ )
+ this.next.callsFake(() => done())
+ this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+ this.req,
+ this.res,
+ this.next
+ )
+ })
+
+ it('calls next', function () {
+ expect(
+ this.CollaboratorsGetter.promises
+ .isUserInvitedReadWriteMemberOfProject
+ ).to.have.been.calledWith(this.user._id, this.project._id)
+ expect(
+ this.CollaboratorsGetter.promises.userIsReadWriteTokenMember
+ ).to.have.been.calledWith(this.user._id, this.project._id)
+ expect(this.next).to.have.been.calledOnce
+ expect(this.next.firstCall.args[0]).to.not.exist
+ })
+ })
+
+ describe('when user is already an invited editor', function () {
+ beforeEach(function (done) {
+ this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
+ true
+ )
+ this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+ this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('redirects to the project/editor', function () {
+ expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+ this.req,
+ this.res,
+ `/project/${this.project._id}`
+ )
+ })
+ })
+
+ describe('when user not a read write token member', function () {
+ beforeEach(function (done) {
+ this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
+ false
+ )
+ this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
+ this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('redirects to the project/editor', function () {
+ expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
+ this.req,
+ this.res,
+ `/project/${this.project._id}`
+ )
+ })
+ })
+ })
+ })
+
+ describe('moveReadWriteToCollaborators', function () {
+ beforeEach(function () {
+ this.req.params = { Project_id: this.project._id }
+ })
+
+ describe('read only invited viewer gaining edit access via link sharing', function () {
+ beforeEach(function (done) {
+ this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
+ true
+ )
+ this.res.callback = done
+ this.TokenAccessController.moveReadWriteToCollaborators(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('sets the privilege level to read and write for the invited viewer', function () {
+ expect(
+ this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+ ).to.have.been.calledWith(
+ this.project._id,
+ this.user._id,
+ PrivilegeLevels.READ_AND_WRITE
+ )
+ expect(this.res.sendStatus).to.have.been.calledWith(204)
+ })
+ })
+ describe('previously joined token access user moving to named collaborator', function () {
+ beforeEach(function (done) {
+ this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
+ false
+ )
+ this.res.callback = done
+ this.TokenAccessController.moveReadWriteToCollaborators(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('sets the privilege level to read and write for the invited viewer', function () {
+ expect(
+ this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
+ ).to.have.been.calledWith(this.user._id, this.project._id)
+ expect(
+ this.CollaboratorsHandler.promises.addUserIdToProject
+ ).to.have.been.calledWith(
+ this.project._id,
+ undefined,
+ this.user._id,
+ PrivilegeLevels.READ_AND_WRITE
+ )
+ expect(this.res.sendStatus).to.have.been.calledWith(204)
+ })
+ })
+ })
+
+ describe('moveReadWriteToReadOnly', function () {
+ beforeEach(function () {
+ this.req.params = { Project_id: this.project._id }
+ })
+
+ describe('previously joined token access user moving to anonymous viewer', function () {
+ beforeEach(function (done) {
+ this.res.callback = done
+ this.TokenAccessController.moveReadWriteToReadOnly(
+ this.req,
+ this.res,
+ done
+ )
+ })
+
+ it('removes them from read write token access refs and adds them to read only token access refs', function () {
+ expect(
+ this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly
+ ).to.have.been.calledWith(this.user._id, this.project._id)
+ expect(this.res.sendStatus).to.have.been.calledWith(204)
+ })
+
+ it('writes a project audit log', function () {
+ expect(
+ this.ProjectAuditLogHandler.promises.addEntry
+ ).to.have.been.calledWith(
+ this.project._id,
+ 'readonly-via-sharing-updates',
+ this.user._id,
+ this.req.ip
+ )
+ })
+ })
+ })
})
diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js
index 25c7ab7cfd..b6c0128043 100644
--- a/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js
+++ b/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js
@@ -200,6 +200,59 @@ describe('TokenAccessHandler', function () {
})
})
+ 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: {} }