mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #32111 from overleaf/as-new-link-sharing-backend
[web] Support link sharing using ProjectInvite GitOrigin-RevId: 45d988dd6451f962f2709bb1e72a715570816d9c
This commit is contained in:
@@ -305,6 +305,16 @@ const settings = {
|
||||
},
|
||||
}
|
||||
|
||||
// This secret is used for encrypting sharing link tokens in the database
|
||||
if (process.env.OVERLEAF_INVITE_TOKEN_SECRET) {
|
||||
module.exports.projectInviteEncryptorOptions = {
|
||||
cipherLabel: '2026.3-v3',
|
||||
cipherPasswords: {
|
||||
'2026.3-v3': process.env.OVERLEAF_INVITE_TOKEN_SECRET,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// # OPTIONAL CONFIGURABLE SETTINGS
|
||||
|
||||
if (process.env.OVERLEAF_LEFT_FOOTER != null) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('./types').PrivilegeLevelsType} */
|
||||
import Errors from '../Errors/Errors.js'
|
||||
|
||||
/** @type {typeof import('./types').PrivilegeLevelsType} */
|
||||
const PrivilegeLevels = {
|
||||
NONE: false,
|
||||
READ_ONLY: 'readOnly',
|
||||
@@ -9,4 +11,27 @@ const PrivilegeLevels = {
|
||||
OWNER: 'owner',
|
||||
}
|
||||
|
||||
/** @type {import('./types').PrivilegeLevel[]} */
|
||||
export const OrderedPrivilegeLevels = [
|
||||
PrivilegeLevels.NONE,
|
||||
PrivilegeLevels.READ_ONLY,
|
||||
PrivilegeLevels.REVIEW,
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.OWNER,
|
||||
]
|
||||
|
||||
/** @type {typeof import('./types').isPrivilegeUpgrade} */
|
||||
export function isPrivilegeUpgrade(currentLevel, newLevel) {
|
||||
if (
|
||||
!OrderedPrivilegeLevels.includes(currentLevel) ||
|
||||
!OrderedPrivilegeLevels.includes(newLevel)
|
||||
) {
|
||||
throw new Errors.InvalidError('Invalid privilege level specified')
|
||||
}
|
||||
return (
|
||||
OrderedPrivilegeLevels.indexOf(newLevel) >
|
||||
OrderedPrivilegeLevels.indexOf(currentLevel)
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivilegeLevels
|
||||
|
||||
+6
-1
@@ -12,7 +12,7 @@ export const PrivilegeLevelsType = {
|
||||
NONE: false,
|
||||
READ_ONLY: 'readOnly',
|
||||
READ_AND_WRITE: 'readAndWrite',
|
||||
REVIEW: 'reviewer',
|
||||
REVIEW: 'review',
|
||||
OWNER: 'owner',
|
||||
} as const
|
||||
|
||||
@@ -26,3 +26,8 @@ export const PublicAccessLevelsType = {
|
||||
} as const
|
||||
|
||||
export type PublicAccessLevel = ValueOf<typeof PublicAccessLevelsType>
|
||||
|
||||
export function isPrivilegeUpgrade(
|
||||
currentLevel: PrivilegeLevel,
|
||||
newLevel: PrivilegeLevel
|
||||
): boolean
|
||||
|
||||
@@ -17,8 +17,11 @@ import { expressify } from '@overleaf/promise-utils'
|
||||
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
|
||||
import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
|
||||
import PrivilegeLevels, {
|
||||
isPrivilegeUpgrade,
|
||||
} from '../Authorization/PrivilegeLevels.mjs'
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||
import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.mjs'
|
||||
|
||||
// This rate limiter allows a different number of requests depending on the
|
||||
// number of callaborators a user is allowed. This is implemented by providing
|
||||
@@ -260,6 +263,16 @@ async function generateNewInvite(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
const _renderInvalidPage = function (res, projectId, sharingUpdates) {
|
||||
res.status(404)
|
||||
logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
|
||||
if (sharingUpdates === 'enabled') {
|
||||
res.render('project/invite/not-valid', { title: 'Invalid Invite' })
|
||||
} else {
|
||||
res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' })
|
||||
}
|
||||
}
|
||||
|
||||
async function viewInvite(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const { token } = req.params
|
||||
@@ -267,17 +280,6 @@ async function viewInvite(req, res) {
|
||||
const { variant: sharingUpdates } =
|
||||
await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
|
||||
|
||||
const _renderInvalidPage = function () {
|
||||
res.status(404)
|
||||
logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
|
||||
|
||||
if (sharingUpdates === 'enabled') {
|
||||
res.render('project/invite/not-valid', { title: 'Invalid Invite' })
|
||||
} else {
|
||||
res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' })
|
||||
}
|
||||
}
|
||||
|
||||
// check if the user is already a member of the project
|
||||
const currentUser = SessionManager.getSessionUser(req.session)
|
||||
if (currentUser) {
|
||||
@@ -304,7 +306,7 @@ async function viewInvite(req, res) {
|
||||
// check if invite is gone, or otherwise non-existent
|
||||
if (invite == null) {
|
||||
logger.debug({ projectId }, 'no invite found for this token')
|
||||
return _renderInvalidPage()
|
||||
return _renderInvalidPage(res, projectId, sharingUpdates)
|
||||
}
|
||||
|
||||
// check the user who sent the invite exists
|
||||
@@ -314,7 +316,7 @@ async function viewInvite(req, res) {
|
||||
)
|
||||
if (owner == null) {
|
||||
logger.debug({ projectId }, 'no project owner found')
|
||||
return _renderInvalidPage()
|
||||
return _renderInvalidPage(res, projectId, sharingUpdates)
|
||||
}
|
||||
|
||||
// fetch the project name
|
||||
@@ -323,7 +325,7 @@ async function viewInvite(req, res) {
|
||||
})
|
||||
if (project == null) {
|
||||
logger.debug({ projectId }, 'no project found')
|
||||
return _renderInvalidPage()
|
||||
return _renderInvalidPage(res, projectId, sharingUpdates)
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
@@ -357,14 +359,44 @@ async function viewInvite(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function viewSharingLink(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
|
||||
// ensure the project exists
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
name: 1,
|
||||
})
|
||||
if (project == null) {
|
||||
logger.debug({ projectId }, 'no project found')
|
||||
return _renderInvalidPage(res, projectId, 'enabled')
|
||||
}
|
||||
|
||||
const currentUser = SessionManager.getSessionUser(req.session)
|
||||
if (!currentUser) {
|
||||
AuthenticationController.setRedirectInSession(req)
|
||||
return res.redirect('/register')
|
||||
}
|
||||
|
||||
// cleanup if set for register page
|
||||
delete req.session.sharedProjectData
|
||||
|
||||
res.render('project/invite/show', {
|
||||
projectId,
|
||||
title: 'Project Invite',
|
||||
})
|
||||
}
|
||||
|
||||
async function acceptInvite(req, res) {
|
||||
const { Project_id: projectId, token } = req.params
|
||||
const { Project_id: projectId, token: urlToken } = req.params
|
||||
const { token: bodyToken } = req.body
|
||||
const currentUser = SessionManager.getSessionUser(req.session)
|
||||
logger.debug(
|
||||
{ projectId, userId: currentUser._id },
|
||||
'got request to accept invite'
|
||||
)
|
||||
|
||||
const token = urlToken || bodyToken
|
||||
|
||||
const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
|
||||
projectId,
|
||||
token
|
||||
@@ -374,6 +406,54 @@ async function acceptInvite(req, res) {
|
||||
throw new Errors.NotFoundError('no matching invite found')
|
||||
}
|
||||
|
||||
if (invite.subscriptionId) {
|
||||
const isGroupMember =
|
||||
await SubscriptionGroupHandler.promises.isUserPartOfGroup(
|
||||
currentUser._id,
|
||||
invite.subscriptionId
|
||||
)
|
||||
if (!isGroupMember) {
|
||||
throw new Errors.ForbiddenError(
|
||||
'user is not part of subscription group required to accept invite'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// check if the user is already a member of the project and upgrade privileges if possible
|
||||
if (currentUser) {
|
||||
const currentPrivilegeLevel =
|
||||
await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
|
||||
currentUser._id,
|
||||
projectId
|
||||
)
|
||||
|
||||
if (currentPrivilegeLevel !== PrivilegeLevels.NONE) {
|
||||
if (isPrivilegeUpgrade(currentPrivilegeLevel, invite.privileges)) {
|
||||
logger.debug(
|
||||
{
|
||||
projectId,
|
||||
userId: currentUser._id,
|
||||
currentPrivilegeLevel,
|
||||
invitePrivilegeLevel: invite.privileges,
|
||||
},
|
||||
'existing member of project can be upgraded'
|
||||
)
|
||||
await CollaboratorsInviteHandler.promises.upgradeUserPrivileges(
|
||||
invite,
|
||||
projectId,
|
||||
currentUser
|
||||
)
|
||||
}
|
||||
return res.redirect(`/project/${projectId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (invite.privileges === PrivilegeLevels.NONE) {
|
||||
throw new Errors.NotFoundError(
|
||||
'invite has been disabled and user is not an existing member of the project'
|
||||
)
|
||||
}
|
||||
|
||||
await ProjectAuditLogHandler.promises.addEntry(
|
||||
projectId,
|
||||
'accept-invite',
|
||||
@@ -392,6 +472,15 @@ async function acceptInvite(req, res) {
|
||||
currentUser
|
||||
)
|
||||
|
||||
// remove any other pending invites for user
|
||||
const userEmails = await UserGetter.promises.getUserConfirmedEmails(
|
||||
currentUser._id
|
||||
)
|
||||
await CollaboratorsInviteHandler.promises.revokeInviteForUser(
|
||||
projectId,
|
||||
userEmails
|
||||
)
|
||||
|
||||
await EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'project:membership:changed',
|
||||
@@ -412,7 +501,7 @@ async function acceptInvite(req, res) {
|
||||
ownerId: invite.sendingUserId, // only owner can invite others
|
||||
mode: editMode,
|
||||
role: invite.privileges,
|
||||
source: 'email-invite',
|
||||
source: urlToken ? 'email-invite' : 'sharing-link',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -423,6 +512,187 @@ async function acceptInvite(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getSharingLink(req, res) {
|
||||
const { Project_id: projectId } = req.params
|
||||
|
||||
const invite =
|
||||
await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
|
||||
|
||||
if (invite === null || !invite.encryptedToken) {
|
||||
res.sendStatus(404)
|
||||
} else {
|
||||
const token = await CollaboratorsInviteHelper.decryptToken(
|
||||
invite.encryptedToken
|
||||
)
|
||||
res.json({
|
||||
_id: invite._id,
|
||||
token,
|
||||
privileges: invite.privileges,
|
||||
subscriptionId: invite.subscriptionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateSharingLinkSchema = z.object({
|
||||
params: z.object({
|
||||
Project_id: zz.objectId(),
|
||||
}),
|
||||
body: z.object({
|
||||
// We have to use a union here, as Zod enums must be strings,
|
||||
// but NONE is defined as boolean false
|
||||
privileges: z.union([
|
||||
z.literal(PrivilegeLevels.NONE),
|
||||
z.enum([
|
||||
PrivilegeLevels.READ_ONLY,
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.REVIEW,
|
||||
]),
|
||||
]),
|
||||
subscriptionId: zz.objectId().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
async function updateSharingLink(req, res) {
|
||||
const { params, body } = parseReq(req, updateSharingLinkSchema)
|
||||
const projectId = params.Project_id
|
||||
const privileges = body.privileges
|
||||
const subscriptionId = body.subscriptionId
|
||||
|
||||
let invite =
|
||||
await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
|
||||
|
||||
const currentUser = SessionManager.getSessionUser(req.session)
|
||||
if (invite === null) {
|
||||
invite = await CollaboratorsInviteHandler.promises.createSharingLinkInvite(
|
||||
projectId,
|
||||
privileges,
|
||||
subscriptionId
|
||||
)
|
||||
await ProjectAuditLogHandler.promises.addEntry(
|
||||
projectId,
|
||||
'sharing-link-created',
|
||||
currentUser._id,
|
||||
req.ip,
|
||||
{
|
||||
inviteId: invite._id,
|
||||
privileges: invite.privileges,
|
||||
subscriptionId: invite.subscriptionId,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
invite.privileges = privileges
|
||||
invite.subscriptionId = subscriptionId
|
||||
await invite.save()
|
||||
await ProjectAuditLogHandler.promises.addEntry(
|
||||
projectId,
|
||||
'sharing-link-updated',
|
||||
currentUser._id,
|
||||
req.ip,
|
||||
{
|
||||
inviteId: invite._id,
|
||||
privileges: invite.privileges,
|
||||
subscriptionId: invite.subscriptionId,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const token = await CollaboratorsInviteHelper.decryptToken(
|
||||
invite.encryptedToken
|
||||
)
|
||||
res.json({
|
||||
_id: invite._id,
|
||||
token,
|
||||
privileges: invite.privileges,
|
||||
subscriptionId: invite.subscriptionId,
|
||||
})
|
||||
}
|
||||
|
||||
const validateSharingLinkSchema = z.object({
|
||||
params: z.object({
|
||||
Project_id: zz.objectId(),
|
||||
}),
|
||||
body: z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
async function validateSharingLink(req, res) {
|
||||
const { params, body } = parseReq(req, validateSharingLinkSchema)
|
||||
const projectId = params.Project_id
|
||||
const { token } = body
|
||||
|
||||
const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
|
||||
projectId,
|
||||
token
|
||||
)
|
||||
|
||||
const currentUser = SessionManager.getSessionUser(req.session)
|
||||
|
||||
if (invite == null || !currentUser) {
|
||||
return res.json({ valid: false })
|
||||
}
|
||||
|
||||
const currentPrivilegeLevel =
|
||||
await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
|
||||
currentUser._id,
|
||||
projectId
|
||||
)
|
||||
|
||||
// fetch the project name
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
name: 1,
|
||||
})
|
||||
|
||||
// If the user is already a member, check if they should be redirected to the project page
|
||||
// or shown the sharing link page based on whether the invite would upgrade their privileges
|
||||
if (currentPrivilegeLevel !== PrivilegeLevels.NONE) {
|
||||
if (
|
||||
// User can't be upgraded any further
|
||||
[PrivilegeLevels.OWNER, PrivilegeLevels.READ_AND_WRITE].includes(
|
||||
currentPrivilegeLevel
|
||||
) ||
|
||||
// Invite is either disabled or read-only, so can't be an upgrade
|
||||
[PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE].includes(
|
||||
invite.privileges
|
||||
) ||
|
||||
// User already has the privileges of the invite
|
||||
currentPrivilegeLevel === invite.privileges
|
||||
) {
|
||||
return res.json({ valid: true, redirect: true })
|
||||
} else {
|
||||
return res.json({
|
||||
valid: true,
|
||||
projectName: project.name,
|
||||
redirect: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (invite.privileges === PrivilegeLevels.NONE) {
|
||||
return res.json({ valid: false })
|
||||
}
|
||||
|
||||
if (invite.subscriptionId) {
|
||||
const isGroupMember =
|
||||
await SubscriptionGroupHandler.promises.isUserPartOfGroup(
|
||||
currentUser._id,
|
||||
invite.subscriptionId
|
||||
)
|
||||
if (!isGroupMember) {
|
||||
logger.debug(
|
||||
{
|
||||
projectId,
|
||||
userId: currentUser._id,
|
||||
subscriptionId: invite.subscriptionId,
|
||||
},
|
||||
'user is not part of subscription group required to use sharing link'
|
||||
)
|
||||
return res.json({ valid: false })
|
||||
}
|
||||
}
|
||||
return res.json({ valid: true, projectName: project.name })
|
||||
}
|
||||
|
||||
const CollaboratorsInviteController = {
|
||||
getAllInvites: expressify(getAllInvites),
|
||||
inviteToProject: expressify(inviteToProject),
|
||||
@@ -430,6 +700,10 @@ const CollaboratorsInviteController = {
|
||||
generateNewInvite: expressify(generateNewInvite),
|
||||
viewInvite: expressify(viewInvite),
|
||||
acceptInvite: expressify(acceptInvite),
|
||||
viewSharingLink: expressify(viewSharingLink),
|
||||
getSharingLink: expressify(getSharingLink),
|
||||
updateSharingLink: expressify(updateSharingLink),
|
||||
validateSharingLink: expressify(validateSharingLink),
|
||||
_checkShouldInviteEmail,
|
||||
_checkRateLimit,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs'
|
||||
|
||||
async function getAllInvites(projectId) {
|
||||
logger.debug({ projectId }, 'fetching invites for project')
|
||||
const invites = await ProjectInvite.find({ projectId })
|
||||
const invites = await ProjectInvite.find({
|
||||
projectId,
|
||||
reusable: { $ne: true },
|
||||
})
|
||||
.select('_id email privileges')
|
||||
.exec()
|
||||
logger.debug(
|
||||
@@ -39,10 +42,26 @@ async function getInviteByToken(projectId, tokenString) {
|
||||
return invite
|
||||
}
|
||||
|
||||
async function getSharingLinkInvite(projectId) {
|
||||
logger.debug({ projectId }, 'getting sharing link invite for project')
|
||||
const invite = await ProjectInvite.findOne({
|
||||
projectId,
|
||||
reusable: true,
|
||||
}).exec()
|
||||
|
||||
if (invite === null) {
|
||||
logger.debug({ projectId }, 'no sharing link invite found for project')
|
||||
} else {
|
||||
logger.debug({ projectId }, 'found sharing link invite for project')
|
||||
}
|
||||
return invite
|
||||
}
|
||||
|
||||
export default {
|
||||
promises: {
|
||||
getAllInvites,
|
||||
getEditInviteCount,
|
||||
getInviteByToken,
|
||||
getSharingLinkInvite,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import CollaboratorsEmailHandler from './CollaboratorsEmailHandler.mjs'
|
||||
import CollaboratorsHandler from './CollaboratorsHandler.mjs'
|
||||
import CollaboratorsInviteGetter from './CollaboratorsInviteGetter.mjs'
|
||||
import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs'
|
||||
import CollaboratorsGetter from './CollaboratorsGetter.mjs'
|
||||
import UserGetter from '../User/UserGetter.mjs'
|
||||
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
|
||||
import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
|
||||
import PrivilegeLevels, {
|
||||
isPrivilegeUpgrade,
|
||||
} from '../Authorization/PrivilegeLevels.mjs'
|
||||
import LimitationsManager from '../Subscription/LimitationsManager.mjs'
|
||||
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
||||
import _ from 'lodash'
|
||||
@@ -83,6 +86,7 @@ const CollaboratorsInviteHandler = {
|
||||
sendingUserId: sendingUser._id,
|
||||
projectId,
|
||||
privileges,
|
||||
reusable: false,
|
||||
})
|
||||
invite = await invite.save()
|
||||
invite = invite.toObject()
|
||||
@@ -200,18 +204,68 @@ const CollaboratorsInviteHandler = {
|
||||
opts
|
||||
)
|
||||
|
||||
// Remove invite
|
||||
const inviteId = invite._id
|
||||
logger.debug({ projectId, inviteId }, 'removing invite')
|
||||
await ProjectInvite.deleteOne({ _id: inviteId }).exec()
|
||||
CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch(
|
||||
err => {
|
||||
logger.error(
|
||||
{ err, projectId, inviteId },
|
||||
'failed to cancel invite notification'
|
||||
)
|
||||
}
|
||||
// Remove single-use invite
|
||||
if (!invite.reusable) {
|
||||
const inviteId = invite._id
|
||||
logger.debug({ projectId, inviteId }, 'removing invite')
|
||||
await ProjectInvite.deleteOne({ _id: inviteId }).exec()
|
||||
CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch(
|
||||
err => {
|
||||
logger.error(
|
||||
{ err, projectId, inviteId },
|
||||
'failed to cancel invite notification'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async upgradeUserPrivileges(invite, projectId, user) {
|
||||
const currentPrivilegeLevel =
|
||||
await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
|
||||
user._id,
|
||||
projectId
|
||||
)
|
||||
const invitePrivilegeLevel = invite.privileges
|
||||
|
||||
if (isPrivilegeUpgrade(currentPrivilegeLevel, invitePrivilegeLevel)) {
|
||||
logger.debug(
|
||||
{
|
||||
projectId,
|
||||
userId: user._id,
|
||||
currentPrivilegeLevel,
|
||||
invitePrivilegeLevel,
|
||||
},
|
||||
'upgrading user privileges'
|
||||
)
|
||||
await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
|
||||
projectId,
|
||||
user._id,
|
||||
invitePrivilegeLevel
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async createSharingLinkInvite(projectId, privileges, subscriptionId) {
|
||||
logger.debug(
|
||||
{ projectId, privileges, subscriptionId },
|
||||
'adding sharing link invite'
|
||||
)
|
||||
const token = CollaboratorsInviteHelper.generateToken()
|
||||
const encryptedToken = await CollaboratorsInviteHelper.encryptToken(token)
|
||||
const tokenHmac = CollaboratorsInviteHelper.hashInviteToken(token)
|
||||
let invite = new ProjectInvite({
|
||||
encryptedToken,
|
||||
tokenHmac,
|
||||
projectId,
|
||||
privileges,
|
||||
subscriptionId,
|
||||
reusable: true,
|
||||
expires: null,
|
||||
})
|
||||
invite = await invite.save()
|
||||
|
||||
return invite
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Crypto from 'node:crypto'
|
||||
import AccessTokenEncryptor from '@overleaf/access-token-encryptor'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
function generateToken() {
|
||||
const buffer = Crypto.randomBytes(24)
|
||||
@@ -24,8 +27,38 @@ function privilegeLevelToRole(privilegeLevel) {
|
||||
}
|
||||
}
|
||||
|
||||
let accessTokenEncryptor
|
||||
try {
|
||||
accessTokenEncryptor = new AccessTokenEncryptor(
|
||||
Settings.projectInviteEncryptorOptions
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{},
|
||||
'Failed to initialise Link Sharing token encryption. Please ensure OVERLEAF_INVITE_TOKEN_SECRET is set.'
|
||||
)
|
||||
}
|
||||
|
||||
async function encryptToken(token) {
|
||||
if (!accessTokenEncryptor) {
|
||||
throw new Error('Token encryption not configured, could not encrypt token')
|
||||
}
|
||||
logger.debug({ settings: Settings.projectInviteEncryptorOptions })
|
||||
return accessTokenEncryptor.promises.encryptJson(token)
|
||||
}
|
||||
|
||||
async function decryptToken(encryptedToken) {
|
||||
if (!accessTokenEncryptor) {
|
||||
throw new Error('Token encryption not configured, could not encrypt token')
|
||||
}
|
||||
logger.debug({ settings: Settings.projectInviteEncryptorOptions })
|
||||
return accessTokenEncryptor.promises.decryptToJson(encryptedToken)
|
||||
}
|
||||
|
||||
export default {
|
||||
generateToken,
|
||||
hashInviteToken,
|
||||
privilegeLevelToRole,
|
||||
encryptToken,
|
||||
decryptToken,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ const rateLimiters = {
|
||||
points: 25, // just over view-project-invite
|
||||
duration: 60,
|
||||
}),
|
||||
validateSharingLink: new RateLimiter('validate-sharing-link', {
|
||||
points: 25, // just over view-project-invite
|
||||
duration: 60,
|
||||
}),
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -93,6 +97,20 @@ export default {
|
||||
CollaboratorsInviteController.getAllInvites
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/sharing-link',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||
CollaboratorsInviteController.getSharingLink
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/project/:Project_id/sharing-link',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||
CollaboratorsInviteController.updateSharingLink
|
||||
)
|
||||
|
||||
webRouter.delete(
|
||||
'/project/:Project_id/invite/:invite_id',
|
||||
AuthenticationController.requireLogin(),
|
||||
@@ -133,6 +151,42 @@ export default {
|
||||
AnalyticsRegistrationSourceMiddleware.clearSource()
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/share',
|
||||
AnalyticsRegistrationSourceMiddleware.setSource(
|
||||
'collaboration',
|
||||
'project-invite'
|
||||
),
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite),
|
||||
CollaboratorsInviteController.viewSharingLink,
|
||||
AnalyticsRegistrationSourceMiddleware.clearSource()
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/project/:Project_id/share',
|
||||
AnalyticsRegistrationSourceMiddleware.setSource(
|
||||
'collaboration',
|
||||
'project-invite'
|
||||
),
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.acceptProjectInvite),
|
||||
CollaboratorsInviteController.acceptInvite,
|
||||
AnalyticsRegistrationSourceMiddleware.clearSource()
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/project/:Project_id/share/validate',
|
||||
AnalyticsRegistrationSourceMiddleware.setSource(
|
||||
'collaboration',
|
||||
'project-invite'
|
||||
),
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.validateSharingLink),
|
||||
CollaboratorsInviteController.validateSharingLink,
|
||||
AnalyticsRegistrationSourceMiddleware.clearSource()
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/tokens',
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.getProjectTokens),
|
||||
|
||||
@@ -23,6 +23,8 @@ const MANAGED_GROUP_PROJECT_EVENTS = [
|
||||
'revoke-invite',
|
||||
'toggle-access-level',
|
||||
'project-downloaded',
|
||||
'sharing-link-created',
|
||||
'sharing-link-updated',
|
||||
]
|
||||
|
||||
async function findManagedSubscriptions(entry) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
|
||||
import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
|
||||
import PrivilegeLevels, {
|
||||
isPrivilegeUpgrade,
|
||||
} from '../Authorization/PrivilegeLevels.mjs'
|
||||
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
||||
import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
|
||||
import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs'
|
||||
@@ -24,13 +26,6 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||
|
||||
const { getSafeAdminDomainRedirect } = UrlHelper
|
||||
const { canRedirectToAdminDomain } = AdminAuthorizationHelper
|
||||
const orderedPrivilegeLevels = [
|
||||
PrivilegeLevels.NONE,
|
||||
PrivilegeLevels.READ_ONLY,
|
||||
PrivilegeLevels.REVIEW,
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.OWNER,
|
||||
]
|
||||
|
||||
async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) {
|
||||
if (!Object.values(TokenAccessHandler.TOKEN_TYPES).includes(tokenType)) {
|
||||
@@ -44,10 +39,7 @@ async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) {
|
||||
userId,
|
||||
projectId
|
||||
)
|
||||
return (
|
||||
orderedPrivilegeLevels.indexOf(privilegeLevel) >=
|
||||
orderedPrivilegeLevels.indexOf(tokenType)
|
||||
)
|
||||
return !isPrivilegeUpgrade(privilegeLevel, tokenType)
|
||||
}
|
||||
|
||||
const makePostUrl = token => {
|
||||
@@ -240,6 +232,7 @@ async function checkAndGetProjectOrResponseAction(
|
||||
projectId,
|
||||
tokenType
|
||||
)
|
||||
logger.debug({ userHasPrivilege }, 'checking user privilege')
|
||||
if (userHasPrivilege) {
|
||||
return [
|
||||
null,
|
||||
|
||||
@@ -14,6 +14,7 @@ const ExpiryDate = function () {
|
||||
export const ProjectInviteSchema = new Schema(
|
||||
{
|
||||
email: String,
|
||||
encryptedToken: String,
|
||||
tokenHmac: String,
|
||||
sendingUserId: ObjectId,
|
||||
projectId: ObjectId,
|
||||
@@ -28,6 +29,8 @@ export const ProjectInviteSchema = new Schema(
|
||||
default: ExpiryDate,
|
||||
index: { expireAfterSeconds: 10 },
|
||||
},
|
||||
reusable: { type: Boolean, default: false },
|
||||
subscriptionId: ObjectId,
|
||||
},
|
||||
{
|
||||
collection: 'projectInvites',
|
||||
|
||||
@@ -5,6 +5,14 @@ import useAsync from '@/shared/hooks/use-async'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import InviteNotValid from './invite-not-valid'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type ValidateResponse = {
|
||||
valid: boolean
|
||||
projectName?: string
|
||||
redirect?: boolean
|
||||
}
|
||||
|
||||
export default function InviteRoot() {
|
||||
const user = getMeta('ol-user')
|
||||
@@ -12,22 +20,69 @@ export default function InviteRoot() {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
const token = getMeta('ol-inviteToken')
|
||||
const location = useLocation()
|
||||
const hashToken = document.location.hash.replace('#', '')
|
||||
const { isLoading, runAsync } = useAsync()
|
||||
const { isReady } = useWaitForI18n()
|
||||
const [validateResponse, setValidateResponse] = useState<ValidateResponse>({
|
||||
valid: false,
|
||||
})
|
||||
|
||||
// If a sharing link (ie. token is in hash), validate the user can use this link
|
||||
useEffect(() => {
|
||||
const validate = async () => {
|
||||
if (hashToken) {
|
||||
try {
|
||||
const result = await runAsync(
|
||||
postJSON(`/project/${projectId}/share/validate`, {
|
||||
body: { token: hashToken },
|
||||
})
|
||||
)
|
||||
setValidateResponse(result)
|
||||
if (result.redirect) {
|
||||
location.replace(`/project/${projectId}`)
|
||||
}
|
||||
} catch (err) {
|
||||
debugConsole.error('Error validating sharing link', err)
|
||||
setValidateResponse({ valid: false })
|
||||
}
|
||||
} else {
|
||||
setValidateResponse({ valid: false })
|
||||
}
|
||||
}
|
||||
// Always treat URL tokens as valid, they are validated on the backend before
|
||||
// rendering this page.
|
||||
if (token) {
|
||||
setValidateResponse({ valid: true })
|
||||
return
|
||||
}
|
||||
validate()
|
||||
}, [token, hashToken, projectId, runAsync, location])
|
||||
|
||||
const handleSubmit = () => {
|
||||
runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`))
|
||||
.then(() => location.assign(`/project/${projectId}`))
|
||||
.catch(debugConsole.error)
|
||||
if (token) {
|
||||
runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`))
|
||||
.then(() => location.assign(`/project/${projectId}`))
|
||||
.catch(debugConsole.error)
|
||||
} else if (hashToken) {
|
||||
runAsync(
|
||||
postJSON(`/project/${projectId}/share`, { body: { token: hashToken } })
|
||||
)
|
||||
.then(() => location.assign(`/project/${projectId}`))
|
||||
.catch(debugConsole.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
if (!isReady || isLoading || validateResponse.redirect) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ((!token && !hashToken) || !validateResponse.valid) {
|
||||
return <InviteNotValid email={user?.email} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Invite
|
||||
projectName={projectName}
|
||||
projectName={validateResponse.projectName || projectName}
|
||||
email={user.email}
|
||||
submitHandler={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -294,6 +294,13 @@ module.exports = {
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
projectInviteEncryptorOptions: {
|
||||
cipherLabel: '2026.3-v3',
|
||||
cipherPasswords: {
|
||||
'2026.3-v3': 'this-is-a-weak-secret-for-tests-web-2026.3-v3',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports.mergeWith = function (overrides) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from 'chai'
|
||||
import Async from 'async'
|
||||
import User from './helpers/User.mjs'
|
||||
import Subscription from './helpers/Subscription.mjs'
|
||||
import settings from '@overleaf/settings'
|
||||
import CollaboratorsEmailHandler from '../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs'
|
||||
import CollaboratorsInviteHelper from '../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs'
|
||||
@@ -156,6 +157,98 @@ const tryGetInviteList = (user, projectId, callback) => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateSharingLink = (
|
||||
sendingUser,
|
||||
projectId,
|
||||
privileges,
|
||||
opts,
|
||||
callback
|
||||
) => {
|
||||
if (typeof opts === 'function') {
|
||||
callback = opts
|
||||
opts = {}
|
||||
}
|
||||
sendingUser.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
sendingUser.request.post(
|
||||
{
|
||||
uri: `/project/${projectId}/sharing-link`,
|
||||
json: {
|
||||
privileges,
|
||||
...opts,
|
||||
},
|
||||
},
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body).to.include.keys('_id', 'token', 'privileges')
|
||||
callback(null, body)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const getSharingLink = (sendingUser, projectId, callback) => {
|
||||
sendingUser.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
sendingUser.request.get(
|
||||
{
|
||||
uri: `/project/${projectId}/sharing-link`,
|
||||
json: true,
|
||||
},
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
callback(null, response, body)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const validateSharingLink = (user, projectId, token, callback) => {
|
||||
user.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
user.request.post(
|
||||
{
|
||||
uri: `/project/${projectId}/share/validate`,
|
||||
json: { token },
|
||||
},
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
callback(null, response, body)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const acceptSharingLink = (user, projectId, token, callback) => {
|
||||
user.getCsrfToken(err => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
user.request.post(
|
||||
{
|
||||
uri: `/project/${projectId}/share`,
|
||||
json: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
callback
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const tryJoinProject = (user, projectId, callback) => {
|
||||
return user.getCsrfToken(error => {
|
||||
if (error != null) {
|
||||
@@ -852,4 +945,202 @@ describe('ProjectInviteTests', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sharing link routes', function () {
|
||||
beforeEach(function (done) {
|
||||
this.projectName = `sharing-link-test-${Math.random()}`
|
||||
createProject(this.sendingUser, this.projectName, (err, projectId) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
this.projectId = projectId
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should create and fetch sharing link without listing reusable invite in invite list', function (done) {
|
||||
Async.series(
|
||||
[
|
||||
cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
|
||||
cb => {
|
||||
updateSharingLink(
|
||||
this.sendingUser,
|
||||
this.projectId,
|
||||
'readOnly',
|
||||
(err, link) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
this.sharingLink = link
|
||||
expect(link.privileges).to.equal('readOnly')
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
|
||||
cb => {
|
||||
getSharingLink(
|
||||
this.sendingUser,
|
||||
this.projectId,
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body.privileges).to.equal('readOnly')
|
||||
expect(body.token).to.equal(this.sharingLink.token)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should validate and accept sharing link via /share flow', function (done) {
|
||||
Async.series(
|
||||
[
|
||||
cb => this.user.login(cb),
|
||||
cb => {
|
||||
updateSharingLink(
|
||||
this.sendingUser,
|
||||
this.projectId,
|
||||
'readOnly',
|
||||
(err, link) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
this.sharingLink = link
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => expectNoProjectAccess(this.user, this.projectId, cb),
|
||||
cb => {
|
||||
validateSharingLink(
|
||||
this.user,
|
||||
this.projectId,
|
||||
this.sharingLink.token,
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body.valid).to.equal(true)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => {
|
||||
acceptSharingLink(
|
||||
this.user,
|
||||
this.projectId,
|
||||
this.sharingLink.token,
|
||||
(err, response) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(302)
|
||||
expect(response.headers.location).to.equal(
|
||||
`/project/${this.projectId}`
|
||||
)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => expectProjectAccess(this.user, this.projectId, cb),
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject invalid sharing link token in validation', function (done) {
|
||||
Async.series(
|
||||
[
|
||||
cb => this.user.login(cb),
|
||||
cb => {
|
||||
validateSharingLink(
|
||||
this.user,
|
||||
this.projectId,
|
||||
'not-a-valid-token',
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body.valid).to.equal(false)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject validation for user not in required subscription group', function (done) {
|
||||
if (!Features.hasFeature('saas')) {
|
||||
this.skip()
|
||||
}
|
||||
const subscription = new Subscription({
|
||||
adminId: this.sendingUser._id,
|
||||
memberIds: [this.sendingUser._id],
|
||||
})
|
||||
let sharingLink
|
||||
Async.series(
|
||||
[
|
||||
cb => this.user.login(cb),
|
||||
cb => subscription.ensureExists(cb),
|
||||
cb => {
|
||||
updateSharingLink(
|
||||
this.sendingUser,
|
||||
this.projectId,
|
||||
'readOnly',
|
||||
{ subscriptionId: subscription._id.toString() },
|
||||
(err, link) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
sharingLink = link
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => {
|
||||
validateSharingLink(
|
||||
this.user,
|
||||
this.projectId,
|
||||
sharingLink.token,
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body.valid).to.equal(false)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => subscription.addMember(this.user._id, cb),
|
||||
cb => {
|
||||
validateSharingLink(
|
||||
this.user,
|
||||
this.projectId,
|
||||
sharingLink.token,
|
||||
(err, response, body) => {
|
||||
if (err) {
|
||||
return cb(err)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(body.valid).to.equal(true)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect } from 'vitest'
|
||||
import Errors from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import PrivilegeLevels, {
|
||||
isPrivilegeUpgrade,
|
||||
} from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
|
||||
|
||||
describe('PrivilegeLevels', function () {
|
||||
describe('isPrivilegeUpgrade', function () {
|
||||
it('returns true when upgrading from NONE to READ_ONLY', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.READ_ONLY)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('returns true when upgrading from READ_ONLY to REVIEW', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('returns true when upgrading from REVIEW to READ_AND_WRITE', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.REVIEW,
|
||||
PrivilegeLevels.READ_AND_WRITE
|
||||
)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('returns true when upgrading from READ_AND_WRITE to OWNER', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.OWNER
|
||||
)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('returns true when upgrading by more than one step', function () {
|
||||
expect(isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.OWNER)).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('returns false when the levels are equal', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.READ_ONLY)
|
||||
).to.be.false
|
||||
expect(isPrivilegeUpgrade(PrivilegeLevels.OWNER, PrivilegeLevels.OWNER))
|
||||
.to.be.false
|
||||
})
|
||||
|
||||
it('returns false when downgrading from OWNER to READ_AND_WRITE', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.OWNER,
|
||||
PrivilegeLevels.READ_AND_WRITE
|
||||
)
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('returns false when downgrading from READ_AND_WRITE to REVIEW', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.REVIEW
|
||||
)
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('returns false when downgrading from REVIEW to READ_ONLY', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.REVIEW, PrivilegeLevels.READ_ONLY)
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('returns false when downgrading to NONE', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE)
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('treats REVIEW as higher than READ_ONLY but lower than READ_AND_WRITE', function () {
|
||||
expect(
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW)
|
||||
).to.be.true
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.REVIEW,
|
||||
PrivilegeLevels.READ_AND_WRITE
|
||||
)
|
||||
).to.be.true
|
||||
expect(
|
||||
isPrivilegeUpgrade(
|
||||
PrivilegeLevels.READ_AND_WRITE,
|
||||
PrivilegeLevels.REVIEW
|
||||
)
|
||||
).to.be.false
|
||||
})
|
||||
|
||||
it('throws InvalidError when currentLevel is not a known privilege level', function () {
|
||||
expect(() =>
|
||||
isPrivilegeUpgrade('bogus', PrivilegeLevels.READ_ONLY)
|
||||
).to.throw(Errors.InvalidError)
|
||||
})
|
||||
|
||||
it('throws InvalidError when newLevel is not a known privilege level', function () {
|
||||
expect(() =>
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, 'bogus')
|
||||
).to.throw(Errors.InvalidError)
|
||||
})
|
||||
|
||||
it('throws InvalidError when both levels are invalid', function () {
|
||||
expect(() => isPrivilegeUpgrade('foo', 'bar')).to.throw(
|
||||
Errors.InvalidError
|
||||
)
|
||||
})
|
||||
|
||||
it('throws InvalidError for undefined inputs', function () {
|
||||
expect(() =>
|
||||
isPrivilegeUpgrade(undefined, PrivilegeLevels.READ_ONLY)
|
||||
).to.throw(Errors.InvalidError)
|
||||
expect(() =>
|
||||
isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, undefined)
|
||||
).to.throw(Errors.InvalidError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -629,7 +629,7 @@ describe('CollaboratorsGetter', function () {
|
||||
expect(privilegeLevel).to.equal('readOnly')
|
||||
})
|
||||
|
||||
it('should return none for non-members', function (ctx) {
|
||||
it('should return false for non-members', function (ctx) {
|
||||
const projectAccess = new ctx.CollaboratorsGetter.ProjectAccess(
|
||||
ctx.project
|
||||
)
|
||||
|
||||
@@ -71,18 +71,22 @@ describe('CollaboratorsInviteController', function () {
|
||||
promises: {
|
||||
getUserByAnyEmail: sinon.stub(),
|
||||
getUser: sinon.stub().resolves(ctx.currentUser),
|
||||
getUserConfirmedEmails: sinon
|
||||
.stub()
|
||||
.resolves([{ email: ctx.currentUser.email }]),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.ProjectGetter = {
|
||||
promises: {
|
||||
getProject: sinon.stub(),
|
||||
getProject: sinon.stub().resolves(ctx.project),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.CollaboratorsGetter = {
|
||||
promises: {
|
||||
isUserInvitedMemberOfProject: sinon.stub(),
|
||||
getMemberIdPrivilegeLevel: sinon.stub().resolves(false),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,6 +96,9 @@ describe('CollaboratorsInviteController', function () {
|
||||
generateNewInvite: sinon.stub().resolves(ctx.invite),
|
||||
revokeInvite: sinon.stub().resolves(ctx.invite),
|
||||
acceptInvite: sinon.stub(),
|
||||
upgradeUserPrivileges: sinon.stub().resolves(),
|
||||
createSharingLinkInvite: sinon.stub().resolves(ctx.invite),
|
||||
revokeInviteForUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,9 +106,26 @@ describe('CollaboratorsInviteController', function () {
|
||||
promises: {
|
||||
getAllInvites: sinon.stub(),
|
||||
getInviteByToken: sinon.stub().resolves(ctx.invite),
|
||||
getSharingLinkInvite: sinon.stub().resolves(ctx.invite),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.CollaboratorsInviteHelper = {
|
||||
decryptToken: sinon.stub().resolves(ctx.token),
|
||||
privilegeLevelToRole: sinon.stub().callsFake(privilege => {
|
||||
if (privilege === 'readOnly') {
|
||||
return 'Viewer'
|
||||
}
|
||||
if (privilege === 'readAndWrite') {
|
||||
return 'Editor'
|
||||
}
|
||||
if (privilege === 'review') {
|
||||
return 'Reviewer'
|
||||
}
|
||||
return privilege
|
||||
}),
|
||||
}
|
||||
|
||||
ctx.EditorRealTimeController = {
|
||||
emitToRoom: sinon.stub(),
|
||||
}
|
||||
@@ -119,6 +143,12 @@ describe('CollaboratorsInviteController', function () {
|
||||
setRedirectInSession: sinon.stub(),
|
||||
}
|
||||
|
||||
ctx.SubscriptionGroupHandler = {
|
||||
promises: {
|
||||
isUserPartOfGroup: sinon.stub().resolves(true),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.SplitTestHandler = {
|
||||
promises: {
|
||||
getAssignment: sinon.stub().resolves({ variant: 'default' }),
|
||||
@@ -169,6 +199,13 @@ describe('CollaboratorsInviteController', function () {
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs',
|
||||
() => ({
|
||||
default: ctx.CollaboratorsInviteHelper,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Editor/EditorRealTimeController.mjs',
|
||||
() => ({
|
||||
@@ -200,19 +237,26 @@ describe('CollaboratorsInviteController', function () {
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Authentication/AuthenticationController',
|
||||
'../../../../app/src/Features/Authentication/AuthenticationController.mjs',
|
||||
() => ({
|
||||
default: ctx.AuthenticationController,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/SplitTests/SplitTestHandler',
|
||||
'../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
|
||||
() => ({
|
||||
default: ctx.SplitTestHandler,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler.mjs',
|
||||
() => ({
|
||||
default: ctx.SubscriptionGroupHandler,
|
||||
})
|
||||
)
|
||||
|
||||
ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default
|
||||
|
||||
ctx.res = new MockResponse(vi)
|
||||
@@ -1645,6 +1689,10 @@ describe('CollaboratorsInviteController', function () {
|
||||
Project_id: ctx.projectId,
|
||||
token: ctx.token,
|
||||
}
|
||||
ctx.req.body = {}
|
||||
ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
describe('when acceptInvite does not produce an error', function () {
|
||||
@@ -1695,6 +1743,25 @@ describe('CollaboratorsInviteController', function () {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('records sharing-link source when token comes from request body', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.params.token = undefined
|
||||
ctx.req.body = { token: ctx.token }
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.acceptInvite(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
ctx.next
|
||||
)
|
||||
})
|
||||
|
||||
ctx.AnalyticsManger.recordEventForUserInBackground.should.have.been.calledWith(
|
||||
ctx.currentUser._id,
|
||||
'project-joined',
|
||||
sinon.match({ source: 'sharing-link' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the invite is not found', function () {
|
||||
@@ -1770,6 +1837,129 @@ describe('CollaboratorsInviteController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSharingLink', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.projectId }
|
||||
ctx.invite.encryptedToken = 'encrypted-token'
|
||||
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
|
||||
ctx.invite
|
||||
)
|
||||
ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token)
|
||||
})
|
||||
|
||||
it('returns sharing link payload when reusable invite exists', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({
|
||||
_id: ctx.invite._id,
|
||||
token: ctx.token,
|
||||
privileges: ctx.invite.privileges,
|
||||
subscriptionId: ctx.invite.subscriptionId,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when sharing link does not exist', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
|
||||
null
|
||||
)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
expect(ctx.res.sendStatus).toHaveBeenCalledWith(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSharingLink', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.projectId }
|
||||
ctx.req.body = {
|
||||
privileges: 'readOnly',
|
||||
}
|
||||
ctx.invite.encryptedToken = 'encrypted-token'
|
||||
ctx.invite.save = sinon.stub().resolves(ctx.invite)
|
||||
ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token)
|
||||
})
|
||||
|
||||
it('creates sharing link invite when none exists', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
|
||||
null
|
||||
)
|
||||
ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.resolves(
|
||||
ctx.invite
|
||||
)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.should.have.been.calledWith(
|
||||
ctx.projectId,
|
||||
'readOnly',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('updates existing sharing link invite', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
|
||||
ctx.invite
|
||||
)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
expect(ctx.invite.save).to.have.been.calledOnce
|
||||
expect(ctx.res.json).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSharingLink', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.projectId }
|
||||
ctx.req.body = { token: ctx.token }
|
||||
})
|
||||
|
||||
it('returns valid false when invite not found', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
|
||||
})
|
||||
|
||||
it('returns valid true for logged in user without subscription restriction', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
|
||||
ctx.invite
|
||||
)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({
|
||||
valid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns valid false when subscription group check fails', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.invite.subscriptionId = new ObjectId().toString()
|
||||
ctx.SubscriptionGroupHandler.promises.isUserPartOfGroup.resolves(false)
|
||||
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
|
||||
ctx.invite
|
||||
)
|
||||
ctx.res.callback = () => resolve()
|
||||
ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
|
||||
})
|
||||
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('_checkShouldInviteEmail', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.email = 'user@example.com'
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('CollaboratorsInviteGetter', function () {
|
||||
ctx.CollaboratorsInviteHelper = {
|
||||
generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)),
|
||||
hashInviteToken: sinon.stub().returns(ctx.tokenHmac),
|
||||
decryptToken: sinon.stub().resolves(ctx.token),
|
||||
}
|
||||
|
||||
vi.doMock('../../../../app/src/models/ProjectInvite', () => ({
|
||||
@@ -134,7 +135,7 @@ describe('CollaboratorsInviteGetter', function () {
|
||||
await ctx.call()
|
||||
ctx.ProjectInvite.find.callCount.should.equal(1)
|
||||
ctx.ProjectInvite.find
|
||||
.calledWith({ projectId: ctx.projectId })
|
||||
.calledWith({ projectId: ctx.projectId, reusable: { $ne: true } })
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
@@ -206,4 +207,43 @@ describe('CollaboratorsInviteGetter', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSharingLinkInvite', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.ProjectInvite.findOne.returns({
|
||||
exec: sinon.stub().resolves(ctx.fakeInvite),
|
||||
})
|
||||
ctx.call = async () => {
|
||||
return await ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite(
|
||||
ctx.projectId
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the sharing link invite', async function (ctx) {
|
||||
const invite = await ctx.call()
|
||||
expect(invite).to.deep.equal(ctx.fakeInvite)
|
||||
})
|
||||
|
||||
it('queries reusable invite records', async function (ctx) {
|
||||
await ctx.call()
|
||||
ctx.ProjectInvite.findOne.should.have.been.calledWith({
|
||||
projectId: ctx.projectId,
|
||||
reusable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no reusable invite exists', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.ProjectInvite.findOne.returns({
|
||||
exec: sinon.stub().resolves(null),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null', async function (ctx) {
|
||||
const invite = await ctx.call()
|
||||
expect(invite).to.be.null
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
ctx.CollaboratorsHandler = {
|
||||
promises: {
|
||||
addUserIdToProject: sinon.stub(),
|
||||
setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.UserGetter = { promises: { getUser: sinon.stub() } }
|
||||
@@ -46,6 +47,7 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
ctx.CollaboratorsInviteHelper = {
|
||||
generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)),
|
||||
hashInviteToken: sinon.stub().returns(ctx.tokenHmac),
|
||||
encryptToken: sinon.stub().resolves('encrypted-token'),
|
||||
}
|
||||
|
||||
ctx.CollaboratorsInviteGetter = {
|
||||
@@ -54,6 +56,12 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
},
|
||||
}
|
||||
|
||||
ctx.CollaboratorsGetter = {
|
||||
promises: {
|
||||
getMemberIdPrivilegeLevel: sinon.stub().resolves('readOnly'),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.SplitTestHandler = {
|
||||
promises: {
|
||||
getAssignmentForUser: sinon.stub().resolves(),
|
||||
@@ -76,6 +84,7 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
debug: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
err: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
@@ -127,12 +136,19 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter',
|
||||
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs',
|
||||
() => ({
|
||||
default: ctx.CollaboratorsInviteGetter,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Collaborators/CollaboratorsGetter.mjs',
|
||||
() => ({
|
||||
default: ctx.CollaboratorsGetter,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
|
||||
() => ({
|
||||
@@ -154,10 +170,6 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock('crypto', () => ({
|
||||
default: ctx.CryptogetAssignmentForUser,
|
||||
}))
|
||||
|
||||
ctx.CollaboratorsInviteHandler = (await import(MODULE_PATH)).default
|
||||
|
||||
ctx.projectId = new ObjectId()
|
||||
@@ -600,6 +612,12 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('does not remove reusable invites after accept', async function (ctx) {
|
||||
ctx.fakeInvite.reusable = true
|
||||
await ctx.call()
|
||||
ctx.ProjectInvite.deleteOne.callCount.should.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the project has no more edit collaborator slots', function () {
|
||||
@@ -692,6 +710,70 @@ describe('CollaboratorsInviteHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgradeUserPrivileges', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.call = async () => {
|
||||
await ctx.CollaboratorsInviteHandler.promises.upgradeUserPrivileges(
|
||||
ctx.fakeInvite,
|
||||
ctx.projectId,
|
||||
ctx.user
|
||||
)
|
||||
}
|
||||
ctx.fakeInvite.privileges = 'readAndWrite'
|
||||
})
|
||||
|
||||
it('upgrades the user when invite privileges are higher', async function (ctx) {
|
||||
ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves(
|
||||
'readOnly'
|
||||
)
|
||||
await ctx.call()
|
||||
ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.should.have.been.calledWith(
|
||||
ctx.projectId,
|
||||
ctx.user._id,
|
||||
'readAndWrite'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not update when invite is readOnly', async function (ctx) {
|
||||
ctx.fakeInvite.privileges = 'readOnly'
|
||||
await ctx.call()
|
||||
expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
|
||||
.not.have.been.called
|
||||
})
|
||||
|
||||
it('does not downgrade when invite is disabled (none)', async function (ctx) {
|
||||
ctx.fakeInvite.privileges = false
|
||||
ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves(
|
||||
'readOnly'
|
||||
)
|
||||
await ctx.call()
|
||||
expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
|
||||
.not.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSharingLinkInvite', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.ProjectInvite.prototype.save.callsFake(async function () {
|
||||
return this
|
||||
})
|
||||
ctx.call = async () => {
|
||||
return await ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite(
|
||||
ctx.projectId,
|
||||
'readOnly',
|
||||
undefined
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates reusable invite with encrypted token', async function (ctx) {
|
||||
const invite = await ctx.call()
|
||||
expect(ctx.CollaboratorsInviteHelper.encryptToken).to.have.been.calledOnce
|
||||
expect(invite.reusable).to.equal(true)
|
||||
expect(invite.encryptedToken).to.equal('encrypted-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('_tryCancelInviteNotification', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.inviteId = new ObjectId()
|
||||
|
||||
Reference in New Issue
Block a user