Add new interstitial 'Join project' consent page for existing link sharing editors when opening a project (#19066)

* Add helpers for checking and removing user readwrite token membership

* Add sharing-updates page and handlers

* Redirect read write token members to sharing-updates on project load

GitOrigin-RevId: d552a2cd74a9843c6103923b03f137131a48877a
This commit is contained in:
Thomas 2024-06-26 12:35:43 +02:00 committed by Copybot
parent 260fdf1307
commit 94be372b24
17 changed files with 1235 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "",

View file

@ -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 (
<div className="container">
<div className="row">
<div className="col-md-6 col-md-offset-3">
<div className="card sharing-updates">
<div className="row">
<div className="col-md-12">
<h1 className="sharing-updates-h1">
{t('updates_to_project_sharing')}
</h1>
</div>
</div>
<div className="row row-spaced">
<div className="col-md-12">
<p>
<Trans
i18nKey="were_making_some_changes_to_project_sharing_this_means_you_will_be_visible"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/blog/changes-to-project-sharing"
rel="noopener noreferrer"
target="_blank"
/>,
]}
/>
</p>
</div>
</div>
<div className="row row-spaced">
<div className="col-md-12">
<button
className="btn btn-primary"
onClick={() => joinProject()}
disabled={isLoading}
>
{t('ok_continue_to_project')}
</button>
</div>
</div>
{isError && (
<div className="row row-spaced">
<div className="col-md-12">
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
</div>
</div>
)}
<div className="row row-spaced">
<div className="col-md-12">
<p>
<small>
<Trans
i18nKey="you_can_also_choose_to_view_anonymously_or_leave_the_project"
components={[
// eslint-disable-next-line react/jsx-key
<button
className="btn btn-inline-link"
onClick={() => viewProject()}
disabled={isLoading || isSuccess}
/>,
// eslint-disable-next-line react/jsx-key
<button
className="btn btn-inline-link"
onClick={() => leaveProject()}
disabled={isLoading || isSuccess}
/>,
]}
/>
</small>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default withErrorBoundary(
SharingUpdatesRoot,
GenericErrorBoundaryFallback
)

View file

@ -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(<SharingUpdatesRoot />, element)
}

View file

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

View file

@ -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 Features</0>",
@ -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": "Wed love you to stay",
"welcome_to_sl": "Welcome to __appName__",
"were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "Were making some <0>changes to project sharing</0>. 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": "Were 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": "Weve recently <0>reduced the compile timeout limit</0> on our free plan, which may have affected this project.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "Weve recently <0>reduced the compile timeout limit</0> 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": "Youre on an __appName__ Paid plan. <0>Contact support</0> 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 member</1> 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>member</1> 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 anonymously</0> (you will lose edit access) or <1>leave the project</1>.",
"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 features</0>.",
"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.",

View file

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

View file

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

View file

@ -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 () {

View file

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

View file

@ -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: {} }