first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { promisify } from 'node:util'
import { expect } from 'chai'
import Features from '../../../app/src/infrastructure/Features.js'
import MetricsHelper from './helpers/metrics.mjs'
import UserHelper from './helpers/User.mjs'
const sleep = promisify(setTimeout)
const User = UserHelper.promises
const getMetric = MetricsHelper.promises.getMetric
async function getActiveUsersMetric() {
return getMetric(line => line.startsWith('num_active_users'))
}
describe('ActiveUsersMetricTests', function () {
before(async function () {
if (Features.hasFeature('saas')) {
this.skip()
}
})
it('updates "num_active_users" metric after a new user opens a project', async function () {
expect(await getActiveUsersMetric()).to.equal(0)
this.user = new User()
await this.user.login()
const projectId = await this.user.createProject('test project')
await this.user.openProject(projectId)
// settings.activeUserMetricInterval is configured to 100ms
await sleep(110)
expect(await getActiveUsersMetric()).to.equal(1)
})
})

View File

@@ -0,0 +1,193 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import logger from '@overleaf/logger'
import sinon from 'sinon'
import { db } from '../../../app/src/infrastructure/mongodb.js'
import Features from '../../../app/src/infrastructure/Features.js'
const User = UserHelper.promises
describe('Add secondary email address confirmation code email', function () {
let spy
let user, user2, res, confirmCode
const extractConfirmCode = () => {
const emailDebugLog = spy.args.find(
([, msg]) => msg === 'Would send email if enabled.'
)
const emailConfirmSubject = emailDebugLog[0].options.subject
return emailConfirmSubject.match(/\((\d{6})\)/)[1]
}
beforeEach(async function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
spy = sinon.spy(logger, 'info')
user = new User()
await user.register()
await user.login()
spy.resetHistory()
res = await user.doRequest('POST', {
json: {
email: 'secondary@overleaf.com',
},
uri: `/user/emails/secondary`,
})
confirmCode = extractConfirmCode()
})
afterEach(function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
spy.restore()
})
it('should send email with confirmation code', function () {
expect(res.response.statusCode).to.equal(200)
expect(confirmCode.length).to.equal(6)
})
describe('with a valid confirmation code', function () {
beforeEach(async function () {
this.result = await user.doRequest('POST', {
json: {
code: confirmCode,
},
uri: '/user/emails/confirm-secondary',
})
})
it('should redirect to /project', async function () {
expect(this.result.response.statusCode).to.equal(200)
expect(this.result.body.redir).to.equal('/project')
})
it('the new email should be saved in mongo', async function () {
const userInDb = await db.users.findOne(
{ email: user.email },
{ projection: { emails: 1 } }
)
expect(userInDb).to.exist
const newSecondaryEmail = userInDb.emails.find(
email => email.email === 'secondary@overleaf.com'
)
expect(newSecondaryEmail).to.exist
expect(newSecondaryEmail.confirmedAt).to.exist
expect(newSecondaryEmail.reconfirmedAt).to.exist
expect(newSecondaryEmail.reconfirmedAt).to.deep.equal(
newSecondaryEmail.confirmedAt
)
})
})
describe('with an invalid confirmation code', function () {
beforeEach(async function () {
this.result = await user.doRequest('POST', {
json: {
code: '123',
},
uri: '/user/emails/confirm-secondary',
})
})
it('should respond with invalid confirmation code error', async function () {
expect(this.result.response.statusCode).to.equal(403)
expect(this.result.body.message.key).to.equal('invalid_confirmation_code')
})
})
describe('with a duplicate email', async function () {
beforeEach(async function () {
await user.doRequest('POST', {
json: {
code: confirmCode,
},
uri: '/user/emails/confirm-secondary',
})
user2 = new User()
await user2.register()
await user2.login()
})
it('should respond with a email already registered error', async function () {
res = await user2.doRequest('POST', {
json: {
email: 'secondary@overleaf.com',
},
uri: `/user/emails/secondary`,
})
expect(res.response.statusCode).to.equal(409)
expect(res.body.message.text).to.equal('This email is already registered')
})
})
it('should hit rate limit on code check', async function () {
let confirmEmailReq
for (let i = 0; i < 20; i++) {
confirmEmailReq = await user.doRequest('POST', {
json: {
code: '123',
},
uri: '/user/emails/confirm-secondary',
})
}
expect(confirmEmailReq.response.statusCode).to.equal(429)
})
it('should resend confirm code', async function () {
const oldConfirmCode = extractConfirmCode()
spy.resetHistory()
const resendCodeRes = await user.doRequest('POST', {
uri: '/user/emails/resend-secondary-confirmation',
})
const newConfirmCode = extractConfirmCode()
expect(resendCodeRes.response.statusCode).to.equal(200)
expect(JSON.parse(resendCodeRes.body).message.key).to.equal(
'we_sent_new_code'
)
const oldConfirmRes = await user.doRequest('POST', {
json: {
code: oldConfirmCode,
},
uri: '/user/emails/confirm-secondary',
})
expect(oldConfirmRes.response.statusCode).to.equal(403)
expect(oldConfirmRes.body.message.key).to.equal('invalid_confirmation_code')
const newCodeRes = await user.doRequest('POST', {
json: {
code: newConfirmCode,
},
uri: '/user/emails/confirm-secondary',
})
expect(newCodeRes.response.statusCode).to.equal(200)
expect(newCodeRes.body.redir).to.equal('/project')
})
it('should hit rate limit on code resend', async function () {
let resendCodeReq
for (let i = 0; i < 5; i++) {
resendCodeReq = await user.doRequest('POST', {
json: true,
uri: '/user/emails/resend-secondary-confirmation',
})
}
expect(resendCodeReq.response.statusCode).to.equal(429)
})
})

View File

@@ -0,0 +1,64 @@
import OError from '@overleaf/o-error'
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
describe('AdminEmails', function () {
beforeEach(function (done) {
this.timeout(5000)
done()
})
describe('an admin with an invalid email address', function () {
before(function (done) {
this.badUser = new User({ email: 'alice@evil.com' })
async.series(
[
cb => this.badUser.ensureUserExists(cb),
cb => this.badUser.ensureAdmin(cb),
],
done
)
})
it('should block the user', function (done) {
this.badUser.login(err => {
// User.login refreshes the csrf token after login.
// Seeing the csrf token request fail "after login" indicates a successful login.
expect(OError.getFullStack(err)).to.match(/TaggedError: after login/)
expect(OError.getFullStack(err)).to.match(
/get csrf token failed: status=500 /
)
this.badUser.getProjectListPage((err, statusCode) => {
expect(err).to.not.exist
expect(statusCode).to.equal(500)
done()
})
})
})
})
describe('an admin with a valid email address', function () {
before(function (done) {
this.goodUser = new User({ email: 'alice@example.com' })
async.series(
[
cb => this.goodUser.ensureUserExists(cb),
cb => this.goodUser.ensureAdmin(cb),
],
done
)
})
it('should not block the user', function (done) {
this.goodUser.login(err => {
expect(err).to.not.exist
this.goodUser.getProjectListPage((err, statusCode) => {
expect(err).to.not.exist
expect(statusCode).to.equal(200)
done()
})
})
})
})
})

View File

@@ -0,0 +1,70 @@
import Settings from '@overleaf/settings'
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('AdminOnlyLogin', function () {
let adminUser, regularUser
const flagBefore = Settings.adminOnlyLogin
after(function () {
Settings.adminOnlyLogin = flagBefore
})
beforeEach('create admin user', async function () {
adminUser = new User()
await adminUser.ensureUserExists()
await adminUser.ensureAdmin()
})
beforeEach('create regular user', async function () {
regularUser = new User()
await regularUser.ensureUserExists()
})
async function expectCanLogin(user) {
const response = await user.login()
expect(response.statusCode).to.equal(200)
expect(response.body).to.deep.equal({ redir: '/project' })
}
async function expectRejectedLogin(user) {
try {
await user.login()
expect.fail('expected the login request to fail')
} catch (err) {
expect(err).to.match(/login failed: status=403/)
expect(err.info.body).to.deep.equal({
message: { type: 'error', text: 'Admin only panel' },
})
}
}
describe('adminOnlyLogin=true', function () {
beforeEach(function () {
Settings.adminOnlyLogin = true
})
it('should allow the admin user to login', async function () {
await expectCanLogin(adminUser)
})
it('should block a regular user from login', async function () {
await expectRejectedLogin(regularUser)
})
})
describe('adminOnlyLogin=false', function () {
beforeEach(function () {
Settings.adminOnlyLogin = false
})
it('should allow the admin user to login', async function () {
await expectCanLogin(adminUser)
})
it('should allow a regular user to login', async function () {
await expectCanLogin(regularUser)
})
})
})

View File

@@ -0,0 +1,142 @@
import Settings from '@overleaf/settings'
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import { getSafeAdminDomainRedirect } from '../../../app/src/Features/Helpers/UrlHelper.js'
const User = UserHelper.promises
describe('AdminPrivilegeAvailable', function () {
let adminUser, otherUser
const flagBefore = Settings.adminPrivilegeAvailable
after(function () {
Settings.adminPrivilegeAvailable = flagBefore
})
beforeEach('create admin user', async function () {
adminUser = new User()
await adminUser.ensureUserExists()
await adminUser.ensureAdmin()
await adminUser.login()
})
let projectIdOwned, otherUsersProjectId, otherUsersProjectTokenAccessURL
beforeEach('create owned project', async function () {
projectIdOwned = await adminUser.createProject('owned project')
})
beforeEach('create other user and project', async function () {
otherUser = new User({
email: 'test@non-staff.com',
confirmedAt: new Date(),
})
await otherUser.login()
otherUsersProjectId = await otherUser.createProject('other users project')
await otherUser.makeTokenBased(otherUsersProjectId)
const {
tokens: { readOnly: readOnlyToken },
} = await otherUser.getProject(otherUsersProjectId)
otherUsersProjectTokenAccessURL = `/read/${readOnlyToken}`
})
async function hasAccess(projectId) {
const { response } = await adminUser.doRequest(
'GET',
`/project/${projectId}`
)
return response.statusCode === 200
}
async function displayTokenAccessPage(user) {
const { response } = await user.doRequest(
'GET',
otherUsersProjectTokenAccessURL
)
expect(response.statusCode).to.equal(200)
expect(response.body).to.include(otherUsersProjectTokenAccessURL)
}
describe('adminPrivilegeAvailable=true', function () {
beforeEach(function () {
Settings.adminPrivilegeAvailable = true
})
it('should grant the admin access to owned project', async function () {
expect(await hasAccess(projectIdOwned)).to.equal(true)
})
it('should grant the admin access to non-owned project', async function () {
expect(await hasAccess(otherUsersProjectId)).to.equal(true)
})
it('should display token access page for admin', async function () {
await displayTokenAccessPage(adminUser)
})
it('should display token access page for regular user', async function () {
await displayTokenAccessPage(otherUser)
})
it('should redirect a token grant request to project page', async function () {
const { response } = await adminUser.doRequest('POST', {
url: `${otherUsersProjectTokenAccessURL}/grant`,
json: {
confirmedByUser: true,
},
})
expect(response.statusCode).to.equal(200)
expect(response.body.redirect).to.equal(`/project/${otherUsersProjectId}`)
})
})
describe('adminPrivilegeAvailable=false', function () {
beforeEach(function () {
Settings.adminPrivilegeAvailable = false
})
it('should grant the admin access to owned project', async function () {
expect(await hasAccess(projectIdOwned)).to.equal(true)
})
it('should block the admin from non-owned project', async function () {
expect(await hasAccess(otherUsersProjectId)).to.equal(false)
})
it('should display token access page for admin', async function () {
displayTokenAccessPage(adminUser)
})
it('should display token access page for regular user', async function () {
await displayTokenAccessPage(otherUser)
})
it('should redirect a token grant request to admin panel if belongs to non-staff', async function () {
const { response } = await adminUser.doRequest('POST', {
url: `${otherUsersProjectTokenAccessURL}/grant`,
json: {
confirmedByUser: true,
},
})
expect(response.statusCode).to.equal(200)
expect(response.body.redirect).to.equal(
getSafeAdminDomainRedirect(otherUsersProjectTokenAccessURL)
)
})
it('should redirect a token grant request to project page if belongs to staff', async function () {
const staff = new User({
email: `test@${Settings.adminDomains[0]}`,
confirmedAt: new Date(),
})
await staff.ensureUserExists()
await staff.ensureAdmin()
await staff.login()
const staffProjectId = await staff.createProject('staff user project')
await staff.makeTokenBased(staffProjectId)
const {
tokens: { readOnly: readOnlyTokenAdmin },
} = await staff.getProject(staffProjectId)
const staffProjectTokenAccessURL = `/read/${readOnlyTokenAdmin}`
const { response } = await adminUser.doRequest('POST', {
url: `${staffProjectTokenAccessURL}/grant`,
json: {
confirmedByUser: true,
},
})
expect(response.statusCode).to.equal(200)
expect(response.body.redirect).to.equal(`/project/${staffProjectId}`)
})
})
})

View File

@@ -0,0 +1,155 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
const ObjectId = mongodb.ObjectId
const User = UserHelper.promises
describe('Authentication', function () {
let user
beforeEach('init vars', function () {
user = new User()
})
describe('CSRF regeneration on login', function () {
it('should prevent use of csrf token from before login', function (done) {
user.logout(err => {
if (err) {
return done(err)
}
user.getCsrfToken(err => {
if (err) {
return done(err)
}
const oldToken = user.csrfToken
user.login(err => {
if (err) {
return done(err)
}
expect(oldToken === user.csrfToken).to.equal(false)
user.request.post(
{
headers: {
'x-csrf-token': oldToken,
},
url: '/project/new',
json: { projectName: 'test' },
},
(err, response, body) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(403)
expect(body).to.equal('Forbidden')
done()
}
)
})
})
})
})
})
describe('login', function () {
beforeEach('doLogin', async function () {
await user.login()
})
it('should log the user in', async function () {
const {
response: { statusCode },
} = await user.doRequest('GET', '/project')
expect(statusCode).to.equal(200)
})
it('should emit an user auditLog entry for the login', async function () {
const auditLog = await user.getAuditLog()
const auditLogEntry = auditLog[0]
expect(auditLogEntry).to.exist
expect(auditLogEntry.timestamp).to.exist
expect(auditLogEntry.initiatorId).to.deep.equal(new ObjectId(user.id))
expect(auditLogEntry.userId).to.deep.equal(new ObjectId(user.id))
expect(auditLogEntry.operation).to.equal('login')
expect(auditLogEntry.info).to.deep.equal({
method: 'Password login',
captcha: 'solved',
fromKnownDevice: false,
})
expect(auditLogEntry.ipAddress).to.equal('127.0.0.1')
})
})
describe('failed login', function () {
beforeEach('fetchCsrfToken', async function () {
await user.login()
await user.logout()
await user.getCsrfToken()
})
it('should return a 401, and add an entry to the audit log', async function () {
const {
response: { statusCode },
} = await user.doRequest('POST', {
url: Settings.enableLegacyLogin ? '/login/legacy' : '/login',
json: {
email: user.email,
password: 'foo-bar-baz',
'g-recaptcha-response': 'valid',
},
})
expect(statusCode).to.equal(401)
const auditLog = await user.getAuditLog()
const auditLogEntry = auditLog.pop()
expect(auditLogEntry).to.exist
expect(auditLogEntry.timestamp).to.exist
expect(auditLogEntry.initiatorId).to.deep.equal(new ObjectId(user.id))
expect(auditLogEntry.userId).to.deep.equal(new ObjectId(user.id))
expect(auditLogEntry.operation).to.equal('failed-password-match')
expect(auditLogEntry.info).to.deep.equal({
method: 'Password login',
fromKnownDevice: true,
})
expect(auditLogEntry.ipAddress).to.equal('127.0.0.1')
})
})
describe('rate-limit', function () {
beforeEach('fetchCsrfToken', async function () {
await user.login()
await user.logout()
await user.getCsrfToken()
})
const tryLogin = async (i = 0) => {
const {
response: { statusCode },
} = await user.doRequest('POST', {
url: Settings.enableLegacyLogin ? '/login/legacy' : '/login',
json: {
email: `${user.email}${' '.repeat(i)}`,
password: 'wrong-password',
'g-recaptcha-response': 'valid',
},
})
return statusCode
}
it('should return 429 after 10 unsuccessful login attempts', async function () {
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin()
expect(statusCode).to.equal(401)
}
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin()
expect(statusCode).to.equal(429)
}
})
it('ignore extra spaces in email address', async function () {
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin(i)
expect(statusCode).to.equal(401)
}
for (let i = 0; i < 10; i++) {
const statusCode = await tryLogin(i)
expect(statusCode).to.equal(429)
}
})
})
})

View File

@@ -0,0 +1,766 @@
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
import request from './helpers/request.js'
import settings from '@overleaf/settings'
import Features from '../../../app/src/infrastructure/Features.js'
import expectErrorResponse from './helpers/expectErrorResponse.mjs'
function tryReadAccess(user, projectId, test, callback) {
async.series(
[
cb =>
user.request.get(`/project/${projectId}`, (error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}),
cb =>
user.request.get(
`/project/${projectId}/download/zip`,
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
),
],
callback
)
}
function tryRenameProjectAccess(user, projectId, test, callback) {
user.request.post(
{
uri: `/project/${projectId}/settings`,
json: {
name: 'new name',
},
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
test(response, body)
callback()
}
)
}
function trySettingsWriteAccess(user, projectId, test, callback) {
async.series(
[
cb =>
user.request.post(
{
uri: `/project/${projectId}/settings`,
json: {
compiler: 'latex',
},
},
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
),
],
callback
)
}
function tryProjectAdminAccess(user, projectId, test, callback) {
async.series(
[
cb =>
user.request.post(
{
uri: `/project/${projectId}/rename`,
json: {
newProjectName: 'new-name',
},
},
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
),
cb =>
user.request.post(
{
uri: `/project/${projectId}/settings/admin`,
json: {
publicAccessLevel: 'private',
},
},
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
),
],
callback
)
}
function tryAdminAccess(user, test, callback) {
async.series(
[
cb =>
user.request.get(
{
uri: '/admin',
},
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
),
cb => {
if (!Features.hasFeature('saas')) {
return cb()
}
user.request.get(
{
uri: `/admin/user/${user._id}`,
},
(error, response, body) => {
if (error != null) {
return cb(error)
}
test(response, body)
cb()
}
)
},
],
callback
)
}
function tryContentAccess(user, projectId, test, callback) {
// The real-time service calls this end point to determine the user's
// permissions.
let userId
if (user.id != null) {
userId = user.id
} else {
userId = 'anonymous-user'
}
request.post(
{
url: `/project/${projectId}/join`,
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
json: { userId },
jar: false,
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
test(response, body)
callback()
}
)
}
function expectAdminAccess(user, callback) {
tryAdminAccess(
user,
response => expect(response.statusCode).to.be.oneOf([200, 204]),
callback
)
}
function expectRedirectedAdminAccess(user, callback) {
tryAdminAccess(
user,
response => {
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(
settings.adminUrl + response.request.uri.pathname
)
},
callback
)
}
function expectReadAccess(user, projectId, callback) {
async.series(
[
cb =>
tryReadAccess(
user,
projectId,
(response, body) =>
expect(response.statusCode).to.be.oneOf([200, 204]),
cb
),
cb =>
tryContentAccess(
user,
projectId,
(response, body) =>
expect(body.privilegeLevel).to.be.oneOf([
'owner',
'readAndWrite',
'readOnly',
]),
cb
),
],
callback
)
}
function expectContentWriteAccess(user, projectId, callback) {
tryContentAccess(
user,
projectId,
(response, body) =>
expect(body.privilegeLevel).to.be.oneOf(['owner', 'readAndWrite']),
callback
)
}
function expectRenameProjectAccess(user, projectId, callback) {
tryRenameProjectAccess(
user,
projectId,
(response, body) => {
expect(response.statusCode).to.be.oneOf([200, 204])
},
callback
)
}
function expectSettingsWriteAccess(user, projectId, callback) {
trySettingsWriteAccess(
user,
projectId,
(response, body) => expect(response.statusCode).to.be.oneOf([200, 204]),
callback
)
}
function expectProjectAdminAccess(user, projectId, callback) {
tryProjectAdminAccess(
user,
projectId,
(response, body) => expect(response.statusCode).to.be.oneOf([200, 204]),
callback
)
}
function expectNoReadAccess(user, projectId, callback) {
async.series(
[
cb =>
tryReadAccess(user, projectId, expectErrorResponse.restricted.html, cb),
cb =>
tryContentAccess(
user,
projectId,
(response, body) => {
expect(response.statusCode).to.equal(403)
expect(body).to.equal('Forbidden')
},
cb
),
],
callback
)
}
function expectNoContentWriteAccess(user, projectId, callback) {
tryContentAccess(
user,
projectId,
(response, body) =>
expect(body.privilegeLevel).to.be.oneOf([undefined, null, 'readOnly']),
callback
)
}
function expectNoSettingsWriteAccess(user, projectId, callback) {
trySettingsWriteAccess(
user,
projectId,
expectErrorResponse.restricted.json,
callback
)
}
function expectNoRenameProjectAccess(user, projectId, callback) {
tryRenameProjectAccess(
user,
projectId,
expectErrorResponse.restricted.json,
callback
)
}
function expectNoProjectAdminAccess(user, projectId, callback) {
tryProjectAdminAccess(
user,
projectId,
(response, body) => {
expect(response.statusCode).to.equal(403)
},
callback
)
}
function expectNoAnonymousProjectAdminAccess(user, projectId, callback) {
tryProjectAdminAccess(
user,
projectId,
expectErrorResponse.requireLogin.json,
callback
)
}
function expectChatAccess(user, projectId, callback) {
user.request.get(
{
url: `/project/${projectId}/messages`,
json: true,
},
(error, response) => {
if (error != null) {
return callback(error)
}
expect(response.statusCode).to.equal(200)
callback()
}
)
}
function expectNoChatAccess(user, projectId, callback) {
user.request.get(
{
url: `/project/${projectId}/messages`,
json: true,
},
(error, response) => {
if (error != null) {
return callback(error)
}
expect(response.statusCode).to.equal(403)
callback()
}
)
}
describe('Authorization', function () {
beforeEach(function (done) {
this.timeout(90000)
this.owner = new User()
this.other1 = new User()
this.other2 = new User()
this.anon = new User()
this.site_admin = new User({ email: 'admin@example.com' })
async.parallel(
[
cb => this.owner.login(cb),
cb => this.other1.login(cb),
cb => this.other2.login(cb),
cb => this.anon.getCsrfToken(cb),
cb => {
this.site_admin.ensureUserExists(err => {
if (err) return cb(err)
this.site_admin.ensureAdmin(err => {
if (err != null) {
return cb(err)
}
return this.site_admin.login(cb)
})
})
},
],
done
)
})
describe('private project', function () {
beforeEach(function (done) {
this.owner.createProject('private-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.projectId = projectId
done()
})
})
it('should allow the owner read access to it', function (done) {
expectReadAccess(this.owner, this.projectId, done)
})
it('should allow the owner write access to its content', function (done) {
expectContentWriteAccess(this.owner, this.projectId, done)
})
it('should allow the owner write access to its settings', function (done) {
expectSettingsWriteAccess(this.owner, this.projectId, done)
})
it('should allow the owner to rename the project', function (done) {
expectRenameProjectAccess(this.owner, this.projectId, done)
})
it('should allow the owner project admin access to it', function (done) {
expectProjectAdminAccess(this.owner, this.projectId, done)
})
it('should allow the owner user chat messages access', function (done) {
expectChatAccess(this.owner, this.projectId, done)
})
it('should not allow another user read access to the project', function (done) {
expectNoReadAccess(this.other1, this.projectId, done)
})
it('should not allow another user write access to its content', function (done) {
expectNoContentWriteAccess(this.other1, this.projectId, done)
})
it('should not allow another user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.other1, this.projectId, done)
})
it('should not allow another user to rename the project', function (done) {
expectNoRenameProjectAccess(this.other1, this.projectId, done)
})
it('should not allow another user project admin access to it', function (done) {
expectNoProjectAdminAccess(this.other1, this.projectId, done)
})
it('should not allow another user chat messages access', function (done) {
expectNoChatAccess(this.other1, this.projectId, done)
})
it('should not allow anonymous user read access to it', function (done) {
expectNoReadAccess(this.anon, this.projectId, done)
})
it('should not allow anonymous user write access to its content', function (done) {
expectNoContentWriteAccess(this.anon, this.projectId, done)
})
it('should not allow anonymous user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.anon, this.projectId, done)
})
it('should not allow anonymous user to rename the project', function (done) {
expectNoRenameProjectAccess(this.anon, this.projectId, done)
})
it('should not allow anonymous user project admin access to it', function (done) {
expectNoAnonymousProjectAdminAccess(this.anon, this.projectId, done)
})
it('should not allow anonymous user chat messages access', function (done) {
expectNoChatAccess(this.anon, this.projectId, done)
})
describe('with admin privilege available', function () {
beforeEach(function () {
settings.adminPrivilegeAvailable = true
})
it('should allow site admin users read access to it', function (done) {
expectReadAccess(this.site_admin, this.projectId, done)
})
it('should allow site admin users write access to its content', function (done) {
expectContentWriteAccess(this.site_admin, this.projectId, done)
})
it('should allow site admin users write access to its settings', function (done) {
expectSettingsWriteAccess(this.site_admin, this.projectId, done)
})
it('should allow site admin users to rename the project', function (done) {
expectRenameProjectAccess(this.site_admin, this.projectId, done)
})
it('should allow site admin users project admin access to it', function (done) {
expectProjectAdminAccess(this.site_admin, this.projectId, done)
})
it('should allow site admin users site admin access to site admin endpoints', function (done) {
expectAdminAccess(this.site_admin, done)
})
})
describe('with admin privilege unavailable', function () {
beforeEach(function () {
settings.adminPrivilegeAvailable = false
})
afterEach(function () {
settings.adminPrivilegeAvailable = true
})
it('should not allow site admin users read access to it', function (done) {
expectNoReadAccess(this.site_admin, this.projectId, done)
})
it('should not allow site admin users write access to its content', function (done) {
expectNoContentWriteAccess(this.site_admin, this.projectId, done)
})
it('should not allow site admin users write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.site_admin, this.projectId, done)
})
it('should not allow site admin users to rename the project', function (done) {
expectNoRenameProjectAccess(this.site_admin, this.projectId, done)
})
it('should not allow site admin users project admin access to it', function (done) {
expectNoProjectAdminAccess(this.site_admin, this.projectId, done)
})
it('should redirect site admin users when accessing site admin endpoints', function (done) {
expectRedirectedAdminAccess(this.site_admin, done)
})
})
})
describe('shared project', function () {
beforeEach(function (done) {
this.rw_user = this.other1
this.ro_user = this.other2
this.owner.createProject('private-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.projectId = projectId
this.owner.addUserToProject(
this.projectId,
this.ro_user,
'readOnly',
error => {
if (error != null) {
return done(error)
}
this.owner.addUserToProject(
this.projectId,
this.rw_user,
'readAndWrite',
error => {
if (error != null) {
return done(error)
}
done()
}
)
}
)
})
})
it('should allow the read-only user read access to it', function (done) {
expectReadAccess(this.ro_user, this.projectId, done)
})
it('should allow the read-only user chat messages access', function (done) {
expectChatAccess(this.ro_user, this.projectId, done)
})
it('should not allow the read-only user write access to its content', function (done) {
expectNoContentWriteAccess(this.ro_user, this.projectId, done)
})
it('should not allow the read-only user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.ro_user, this.projectId, done)
})
it('should not allow the read-only user to rename the project', function (done) {
expectNoRenameProjectAccess(this.ro_user, this.projectId, done)
})
it('should not allow the read-only user project admin access to it', function (done) {
expectNoProjectAdminAccess(this.ro_user, this.projectId, done)
})
it('should allow the read-write user read access to it', function (done) {
expectReadAccess(this.rw_user, this.projectId, done)
})
it('should allow the read-write user write access to its content', function (done) {
expectContentWriteAccess(this.rw_user, this.projectId, done)
})
it('should allow the read-write user write access to its settings', function (done) {
expectSettingsWriteAccess(this.rw_user, this.projectId, done)
})
it('should not allow the read-write user to rename the project', function (done) {
expectNoRenameProjectAccess(this.rw_user, this.projectId, done)
})
it('should not allow the read-write user project admin access to it', function (done) {
expectNoProjectAdminAccess(this.rw_user, this.projectId, done)
})
it('should allow the read-write user chat messages access', function (done) {
expectChatAccess(this.rw_user, this.projectId, done)
})
})
describe('public read-write project', function () {
/**
* Note: this is a test for the legacy "public access" feature.
* See documentation comment in `Authorization/PublicAccessLevels`
* */
beforeEach(function (done) {
this.owner.createProject('public-rw-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.projectId = projectId
this.owner.makePublic(this.projectId, 'readAndWrite', done)
})
})
it('should allow a user read access to it', function (done) {
expectReadAccess(this.other1, this.projectId, done)
})
it('should allow a user write access to its content', function (done) {
expectContentWriteAccess(this.other1, this.projectId, done)
})
it('should allow a user chat messages access', function (done) {
expectChatAccess(this.other1, this.projectId, done)
})
it('should not allow a user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.other1, this.projectId, done)
})
it('should not allow a user to rename the project', function (done) {
expectNoRenameProjectAccess(this.other1, this.projectId, done)
})
it('should not allow a user project admin access to it', function (done) {
expectNoProjectAdminAccess(this.other1, this.projectId, done)
})
it('should allow an anonymous user read access to it', function (done) {
expectReadAccess(this.anon, this.projectId, done)
})
it('should allow an anonymous user write access to its content', function (done) {
expectContentWriteAccess(this.anon, this.projectId, done)
})
it('should allow an anonymous user chat messages access', function (done) {
// chat access for anonymous users is a CE/SP-only feature, although currently broken
// https://github.com/overleaf/internal/issues/10944
if (Features.hasFeature('saas')) {
this.skip()
}
expectChatAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user to rename the project', function (done) {
expectNoRenameProjectAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user project admin access to it', function (done) {
expectNoAnonymousProjectAdminAccess(this.anon, this.projectId, done)
})
})
describe('public read-only project', function () {
/**
* Note: this is a test for the legacy "public access" feature.
* See documentation comment in `Authorization/PublicAccessLevels`
* */
beforeEach(function (done) {
this.owner.createProject('public-ro-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.projectId = projectId
this.owner.makePublic(this.projectId, 'readOnly', done)
})
})
it('should allow a user read access to it', function (done) {
expectReadAccess(this.other1, this.projectId, done)
})
it('should not allow a user write access to its content', function (done) {
expectNoContentWriteAccess(this.other1, this.projectId, done)
})
it('should not allow a user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.other1, this.projectId, done)
})
it('should not allow a user to rename the project', function (done) {
expectNoRenameProjectAccess(this.other1, this.projectId, done)
})
it('should not allow a user project admin access to it', function (done) {
expectNoProjectAdminAccess(this.other1, this.projectId, done)
})
// NOTE: legacy readOnly access does not count as 'restricted' in the new model
it('should allow a user chat messages access', function (done) {
expectChatAccess(this.other1, this.projectId, done)
})
it('should allow an anonymous user read access to it', function (done) {
expectReadAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user write access to its content', function (done) {
expectNoContentWriteAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user write access to its settings', function (done) {
expectNoSettingsWriteAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user to rename the project', function (done) {
expectNoRenameProjectAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user project admin access to it', function (done) {
expectNoAnonymousProjectAdminAccess(this.anon, this.projectId, done)
})
it('should not allow an anonymous user chat messages access', function (done) {
expectNoChatAccess(this.anon, this.projectId, done)
})
})
})

View File

@@ -0,0 +1,176 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
async function getDeletedFiles(projectId) {
return (await db.projects.findOne({ _id: projectId })).deletedFiles
}
async function setDeletedFiles(projectId, deletedFiles) {
await db.projects.updateOne({ _id: projectId }, { $set: { deletedFiles } })
}
async function unsetDeletedFiles(projectId) {
await db.projects.updateOne(
{ _id: projectId },
{ $unset: { deletedFiles: 1 } }
)
}
describe('BackFillDeletedFiles', function () {
let user, projectId1, projectId2, projectId3, projectId4, projectId5
beforeEach('create projects', async function () {
user = new User()
await user.login()
projectId1 = new ObjectId(await user.createProject('project1'))
projectId2 = new ObjectId(await user.createProject('project2'))
projectId3 = new ObjectId(await user.createProject('project3'))
projectId4 = new ObjectId(await user.createProject('project4'))
projectId5 = new ObjectId(await user.createProject('project5'))
})
let fileId1, fileId2, fileId3, fileId4
beforeEach('create files', function () {
// take a short cut and just allocate file ids
fileId1 = new ObjectId()
fileId2 = new ObjectId()
fileId3 = new ObjectId()
fileId4 = new ObjectId()
})
const otherFileDetails = {
name: 'universe.jpg',
linkedFileData: null,
hash: 'ed19e7d6779b47d8c63f6fa5a21954dcfb6cac00',
deletedAt: new Date(),
__v: 0,
}
let deletedFiles1, deletedFiles2, deletedFiles3
beforeEach('set deletedFiles details', async function () {
deletedFiles1 = [
{ _id: fileId1, ...otherFileDetails },
{ _id: fileId2, ...otherFileDetails },
]
deletedFiles2 = [{ _id: fileId3, ...otherFileDetails }]
await setDeletedFiles(projectId1, deletedFiles1)
await setDeletedFiles(projectId2, deletedFiles2)
// a project without deletedFiles entries
await setDeletedFiles(projectId3, [])
// a project without deletedFiles array
await unsetDeletedFiles(projectId4)
// duplicate entry
deletedFiles3 = [
{ _id: fileId4, ...otherFileDetails },
{ _id: fileId4, ...otherFileDetails },
]
await setDeletedFiles(projectId5, deletedFiles3)
})
async function runScript(args = []) {
let result
try {
result = await promisify(exec)(
['LET_USER_DOUBLE_CHECK_INPUTS_FOR=1', 'VERBOSE_LOGGING=true']
.concat(['node', 'scripts/back_fill_deleted_files.mjs'])
.concat(args)
.join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
const { stdout: stdOut } = result
expect(stdOut).to.match(
new RegExp(`Running update on batch with ids .+${projectId1}`)
)
expect(stdOut).to.match(
new RegExp(`Running update on batch with ids .+${projectId2}`)
)
expect(stdOut).to.not.match(
new RegExp(`Running update on batch with ids .+${projectId3}`)
)
expect(stdOut).to.not.match(
new RegExp(`Running update on batch with ids .+${projectId4}`)
)
expect(stdOut).to.match(
new RegExp(`Running update on batch with ids .+${projectId5}`)
)
}
function checkAreFilesBackFilled() {
it('should back fill file and set projectId', async function () {
const docs = await db.deletedFiles
.find({}, { sort: { _id: 1 } })
.toArray()
expect(docs).to.deep.equal([
{ _id: fileId1, projectId: projectId1, ...otherFileDetails },
{ _id: fileId2, projectId: projectId1, ...otherFileDetails },
{ _id: fileId3, projectId: projectId2, ...otherFileDetails },
{ _id: fileId4, projectId: projectId5, ...otherFileDetails },
])
})
}
describe('back fill only', function () {
beforeEach('run script', runScript)
checkAreFilesBackFilled()
it('should leave the deletedFiles as is', async function () {
expect(await getDeletedFiles(projectId1)).to.deep.equal(deletedFiles1)
expect(await getDeletedFiles(projectId2)).to.deep.equal(deletedFiles2)
expect(await getDeletedFiles(projectId5)).to.deep.equal(deletedFiles3)
})
})
describe('back fill and cleanup', function () {
beforeEach('run script with cleanup flag', async function () {
await runScript(['--perform-cleanup'])
})
checkAreFilesBackFilled()
it('should cleanup the deletedFiles', async function () {
expect(await getDeletedFiles(projectId1)).to.deep.equal([])
expect(await getDeletedFiles(projectId2)).to.deep.equal([])
expect(await getDeletedFiles(projectId5)).to.deep.equal([])
})
})
describe('fix partial inserts and cleanup', function () {
beforeEach('simulate one missing insert', async function () {
await setDeletedFiles(projectId1, [deletedFiles1[0]])
})
beforeEach('run script with cleanup flag', async function () {
await runScript(['--perform-cleanup'])
})
beforeEach('add case for one missing file', async function () {
await setDeletedFiles(projectId1, deletedFiles1)
})
beforeEach('add cases for no more files to insert', async function () {
await setDeletedFiles(projectId2, deletedFiles2)
await setDeletedFiles(projectId5, deletedFiles3)
})
beforeEach('fixing partial insert and cleanup', async function () {
await runScript(['--fix-partial-inserts', '--perform-cleanup'])
})
checkAreFilesBackFilled()
it('should cleanup the deletedFiles', async function () {
expect(await getDeletedFiles(projectId1)).to.deep.equal([])
expect(await getDeletedFiles(projectId2)).to.deep.equal([])
expect(await getDeletedFiles(projectId5)).to.deep.equal([])
})
})
})

View File

@@ -0,0 +1,121 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import UserHelper from './helpers/User.mjs'
import { renderObjectId } from '@overleaf/mongo-utils/batchedUpdate.js'
const User = UserHelper.promises
async function getDeletedDocs(projectId) {
return (await db.projects.findOne({ _id: projectId })).deletedDocs
}
async function setDeletedDocs(projectId, deletedDocs) {
await db.projects.updateOne({ _id: projectId }, { $set: { deletedDocs } })
}
describe('BackFillDocNameForDeletedDocs', function () {
let user, projectId1, projectId2, docId1, docId2, docId3
beforeEach('create projects', async function () {
user = new User()
await user.login()
projectId1 = new ObjectId(await user.createProject('project1'))
projectId2 = new ObjectId(await user.createProject('project2'))
})
beforeEach('create docs', async function () {
docId1 = new ObjectId(
await user.createDocInProject(projectId1, null, 'doc1.tex')
)
docId2 = new ObjectId(
await user.createDocInProject(projectId1, null, 'doc2.tex')
)
docId3 = new ObjectId(
await user.createDocInProject(projectId2, null, 'doc3.tex')
)
})
beforeEach('deleted docs', async function () {
await user.deleteItemInProject(projectId1, 'doc', docId1)
await user.deleteItemInProject(projectId1, 'doc', docId2)
await user.deleteItemInProject(projectId2, 'doc', docId3)
})
beforeEach('insert doc stubs into docs collection', async function () {
await db.docs.insertMany([
{ _id: docId1, deleted: true },
{ _id: docId2, deleted: true },
{ _id: docId3, deleted: true },
])
})
let deletedDocs1, deletedDocs2
let deletedAt1, deletedAt2, deletedAt3
beforeEach('set deletedDocs details', async function () {
deletedAt1 = new Date()
deletedAt2 = new Date()
deletedAt3 = new Date()
deletedDocs1 = [
{ _id: docId1, name: 'doc1.tex', deletedAt: deletedAt1 },
{ _id: docId2, name: 'doc2.tex', deletedAt: deletedAt2 },
]
deletedDocs2 = [{ _id: docId3, name: 'doc3.tex', deletedAt: deletedAt3 }]
await setDeletedDocs(projectId1, deletedDocs1)
await setDeletedDocs(projectId2, deletedDocs2)
})
async function runScript(args = []) {
let result
try {
result = await promisify(exec)(
['LET_USER_DOUBLE_CHECK_INPUTS_FOR=1']
.concat(['node', 'scripts/back_fill_doc_name_for_deleted_docs.mjs'])
.concat(args)
.join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
const { stderr: stdErr } = result
expect(stdErr).to.include(
`Completed batch ending ${renderObjectId(projectId2)}`
)
}
function checkDocsBackFilled() {
it('should back fill names and deletedAt dates into docs', async function () {
const docs = await db.docs.find({}).toArray()
expect(docs).to.deep.equal([
{ _id: docId1, deleted: true, name: 'doc1.tex', deletedAt: deletedAt1 },
{ _id: docId2, deleted: true, name: 'doc2.tex', deletedAt: deletedAt2 },
{ _id: docId3, deleted: true, name: 'doc3.tex', deletedAt: deletedAt3 },
])
})
}
describe('back fill only', function () {
beforeEach('run script', runScript)
checkDocsBackFilled()
it('should leave the deletedDocs as is', async function () {
expect(await getDeletedDocs(projectId1)).to.deep.equal(deletedDocs1)
expect(await getDeletedDocs(projectId2)).to.deep.equal(deletedDocs2)
})
})
describe('back fill and cleanup', function () {
beforeEach('run script with cleanup flag', async function () {
await runScript(['--perform-cleanup'])
})
checkDocsBackFilled()
it('should cleanup the deletedDocs', async function () {
expect(await getDeletedDocs(projectId1)).to.deep.equal([])
expect(await getDeletedDocs(projectId2)).to.deep.equal([])
})
})
})

View File

@@ -0,0 +1,79 @@
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import { promisify } from 'node:util'
import { exec } from 'node:child_process'
import logger from '@overleaf/logger'
import { expect } from 'chai'
describe('BackFillDocRevTests', function () {
const docId1 = new ObjectId()
const docId2 = new ObjectId()
const docId3 = new ObjectId()
beforeEach('insert docs', async function () {
await db.docs.insertMany([
{ _id: docId1, deleted: true },
{ _id: docId2 },
{ _id: docId3, rev: 42 },
])
})
async function runScript(dryRun) {
let result
try {
result = await promisify(exec)(
[
'VERBOSE_LOGGING=true',
'node',
'scripts/back_fill_doc_rev.mjs',
dryRun,
].join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
const { stdout: stdOut } = result
expect(stdOut).to.include('rev missing 2 | deleted=true 1')
expect(stdOut).to.match(
new RegExp(`Running update on batch with ids .+${docId1}`)
)
expect(stdOut).to.match(
new RegExp(`Running update on batch with ids .+${docId2}`)
)
expect(stdOut).to.not.match(
new RegExp(`Running update on batch with ids .+${docId3}`)
)
}
describe('dry-run=true', function () {
beforeEach('run script', async function () {
await runScript('--dry-run=true')
})
it('should not back fill the rev', async function () {
const docs = await db.docs.find({}, { $sort: { _id: 1 } }).toArray()
expect(docs).to.deep.equal([
{ _id: docId1, deleted: true },
{ _id: docId2 },
{ _id: docId3, rev: 42 },
])
})
})
describe('dry-run=false', function () {
beforeEach('run script', async function () {
await runScript('--dry-run=false')
})
it('should back fill the rev', async function () {
const docs = await db.docs.find({}, { $sort: { _id: 1 } }).toArray()
expect(docs).to.deep.equal([
{ _id: docId1, rev: 1, deleted: true },
{ _id: docId2, rev: 1 },
{ _id: docId3, rev: 42 },
])
})
})
})

View File

@@ -0,0 +1,273 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
const DUMMY_NAME = 'unknown.tex'
const DUMMY_TIME = new Date('2021-04-12T00:00:00.000Z')
const ONE_DAY_IN_S = 60 * 60 * 24
const BATCH_SIZE = 3
function getObjectIdFromDate(date) {
const seconds = new Date(date).getTime() / 1000
return ObjectId.createFromTime(seconds)
}
describe('BackFillDummyDocMeta', function () {
let docIds
let projectIds
let stopAtSeconds
beforeEach('create docs', async function () {
docIds = []
docIds[0] = getObjectIdFromDate('2021-04-01T00:00:00.000Z')
docIds[1] = getObjectIdFromDate('2021-04-02T00:00:00.000Z')
docIds[2] = getObjectIdFromDate('2021-04-11T00:00:00.000Z')
docIds[3] = getObjectIdFromDate('2021-04-12T00:00:00.000Z')
docIds[4] = getObjectIdFromDate('2021-04-13T00:00:00.000Z')
docIds[5] = getObjectIdFromDate('2021-04-14T00:00:00.000Z')
docIds[6] = getObjectIdFromDate('2021-04-15T00:00:00.000Z')
docIds[7] = getObjectIdFromDate('2021-04-16T00:01:00.000Z')
docIds[8] = getObjectIdFromDate('2021-04-16T00:02:00.000Z')
docIds[9] = getObjectIdFromDate('2021-04-16T00:03:00.000Z')
docIds[10] = getObjectIdFromDate('2021-04-16T00:04:00.000Z')
docIds[11] = getObjectIdFromDate('2021-04-16T00:05:00.000Z')
projectIds = []
projectIds[0] = getObjectIdFromDate('2021-04-01T00:00:00.000Z')
projectIds[1] = getObjectIdFromDate('2021-04-02T00:00:00.000Z')
projectIds[2] = getObjectIdFromDate('2021-04-11T00:00:00.000Z')
projectIds[3] = getObjectIdFromDate('2021-04-12T00:00:00.000Z')
projectIds[4] = getObjectIdFromDate('2021-04-13T00:00:00.000Z')
projectIds[5] = getObjectIdFromDate('2021-04-14T00:00:00.000Z')
projectIds[6] = getObjectIdFromDate('2021-04-15T00:00:00.000Z')
projectIds[7] = getObjectIdFromDate('2021-04-16T00:01:00.000Z')
projectIds[8] = getObjectIdFromDate('2021-04-16T00:02:00.000Z')
projectIds[9] = getObjectIdFromDate('2021-04-16T00:03:00.000Z')
// two docs in the same project
projectIds[10] = projectIds[9]
projectIds[11] = projectIds[4]
stopAtSeconds = new Date('2021-04-17T00:00:00.000Z').getTime() / 1000
})
const now = new Date()
beforeEach('insert doc stubs into docs collection', async function () {
await db.docs.insertMany([
// incomplete, without deletedDocs context
{ _id: docIds[0], project_id: projectIds[0], deleted: true },
{ _id: docIds[1], project_id: projectIds[1], deleted: true },
{ _id: docIds[2], project_id: projectIds[2], deleted: true },
{ _id: docIds[3], project_id: projectIds[3], deleted: true },
// incomplete, with deletedDocs context
{ _id: docIds[4], project_id: projectIds[4], deleted: true },
// complete
{
_id: docIds[5],
project_id: projectIds[5],
deleted: true,
name: 'foo.tex',
deletedAt: now,
},
// not deleted
{ _id: docIds[6], project_id: projectIds[6] },
// multiple in a single batch
{ _id: docIds[7], project_id: projectIds[7], deleted: true },
{ _id: docIds[8], project_id: projectIds[8], deleted: true },
{ _id: docIds[9], project_id: projectIds[9], deleted: true },
// two docs in one project
{ _id: docIds[10], project_id: projectIds[10], deleted: true },
{ _id: docIds[11], project_id: projectIds[11], deleted: true },
])
})
beforeEach('insert deleted project context', async function () {
await db.deletedProjects.insertMany([
// projectIds[0] and projectIds[1] have no entry
// hard-deleted
{ deleterData: { deletedProjectId: projectIds[2] } },
// soft-deleted, no entry for doc
{
deleterData: { deletedProjectId: projectIds[3] },
project: { deletedDocs: [] },
},
// soft-deleted, has entry for doc
{
deleterData: { deletedProjectId: projectIds[4] },
project: {
deletedDocs: [
{ _id: docIds[4], name: 'main.tex', deletedAt: now },
{ _id: docIds[11], name: 'main.tex', deletedAt: now },
],
},
},
])
})
let options
async function runScript(dryRun) {
options = {
BATCH_SIZE,
CACHE_SIZE: 100,
DRY_RUN: dryRun,
FIRST_PROJECT_ID: projectIds[0].toString(),
INCREMENT_BY_S: ONE_DAY_IN_S,
STOP_AT_S: stopAtSeconds,
// start right away
LET_USER_DOUBLE_CHECK_INPUTS_FOR: 1,
}
let result
try {
result = await promisify(exec)(
Object.entries(options)
.map(([key, value]) => `${key}=${value}`)
.concat(['node', 'scripts/back_fill_dummy_doc_meta.mjs'])
.join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
let { stderr: stdErr, stdout: stdOut } = result
stdErr = stdErr.split('\n')
stdOut = stdOut.split('\n').filter(filterOutput)
expect(stdOut.filter(filterOutput)).to.include.members([
`Orphaned deleted doc ${docIds[0]} (no deletedProjects entry)`,
`Orphaned deleted doc ${docIds[1]} (no deletedProjects entry)`,
`Orphaned deleted doc ${docIds[2]} (failed hard deletion)`,
`Missing deletedDoc for ${docIds[3]}`,
`Found deletedDoc for ${docIds[4]}`,
`Found deletedDoc for ${docIds[11]}`,
`Orphaned deleted doc ${docIds[7]} (no deletedProjects entry)`,
`Orphaned deleted doc ${docIds[8]} (no deletedProjects entry)`,
`Orphaned deleted doc ${docIds[9]} (no deletedProjects entry)`,
`Orphaned deleted doc ${docIds[10]} (no deletedProjects entry)`,
])
expect(stdErr.filter(filterOutput)).to.include.members([
`Processed 9 until ${projectIds[9]}`,
'Done.',
])
}
describe('DRY_RUN=true', function () {
beforeEach('run script', async function () {
await runScript(true)
})
it('should leave docs as is', async function () {
const docs = await db.docs.find({}).toArray()
expect(docs).to.deep.equal([
{ _id: docIds[0], project_id: projectIds[0], deleted: true },
{ _id: docIds[1], project_id: projectIds[1], deleted: true },
{ _id: docIds[2], project_id: projectIds[2], deleted: true },
{ _id: docIds[3], project_id: projectIds[3], deleted: true },
{ _id: docIds[4], project_id: projectIds[4], deleted: true },
{
_id: docIds[5],
project_id: projectIds[5],
deleted: true,
name: 'foo.tex',
deletedAt: now,
},
{ _id: docIds[6], project_id: projectIds[6] },
{ _id: docIds[7], project_id: projectIds[7], deleted: true },
{ _id: docIds[8], project_id: projectIds[8], deleted: true },
{ _id: docIds[9], project_id: projectIds[9], deleted: true },
{ _id: docIds[10], project_id: projectIds[10], deleted: true },
{ _id: docIds[11], project_id: projectIds[11], deleted: true },
])
})
})
describe('DRY_RUN=false', function () {
beforeEach('run script', async function () {
await runScript(false)
})
it('should back fill name and deletedAt dates into broken docs', async function () {
const docs = await db.docs.find({}).toArray()
expect(docs).to.deep.equal([
{
_id: docIds[0],
project_id: projectIds[0],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[1],
project_id: projectIds[1],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[2],
project_id: projectIds[2],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[3],
project_id: projectIds[3],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[4],
project_id: projectIds[4],
deleted: true,
name: 'main.tex',
deletedAt: now,
},
{
_id: docIds[5],
project_id: projectIds[5],
deleted: true,
name: 'foo.tex',
deletedAt: now,
},
{ _id: docIds[6], project_id: projectIds[6] },
{
_id: docIds[7],
project_id: projectIds[7],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[8],
project_id: projectIds[8],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[9],
project_id: projectIds[9],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[10],
project_id: projectIds[10],
deleted: true,
name: DUMMY_NAME,
deletedAt: DUMMY_TIME,
},
{
_id: docIds[11],
project_id: projectIds[11],
deleted: true,
name: 'main.tex',
deletedAt: now,
},
])
})
})
})

View File

@@ -0,0 +1,164 @@
import { spawnSync } from 'node:child_process'
import { expect } from 'chai'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
describe('BatchedUpdateTests', function () {
it('can handle non linear insert order', async function () {
await db.systemmessages.insertOne({
content: '1',
_id: new ObjectId('500000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '2',
_id: new ObjectId('400000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '3',
_id: new ObjectId('600000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '4',
_id: new ObjectId('300000000000000000000000'),
})
spawnSync(process.argv0, [
'--input-type=module',
'-e',
'import { batchedUpdateWithResultHandling } from "@overleaf/mongo-utils/batchedUpdate.js"; import { db } from "./app/src/infrastructure/mongodb.js"; batchedUpdateWithResultHandling(db.systemmessages, { content: { $ne: "42" }}, { $set: { content: "42" } })',
])
await expect(
db.systemmessages.find({}).project({ content: 1, _id: 0 }).toArray()
).to.eventually.deep.equal([
{ content: '42' },
{ content: '42' },
{ content: '42' },
{ content: '42' },
])
})
it('can handle ids sitting on the edge', async function () {
const edge = '3028de800000000000000000'
await db.systemmessages.insertOne({
content: '1',
_id: new ObjectId('300000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '2',
_id: new ObjectId(),
})
await db.systemmessages.insertOne({
content: '3',
_id: new ObjectId('400000000000000000000000'),
})
const { stderr } = spawnSync(
process.argv0,
[
'--input-type=module',
'-e',
'import { batchedUpdateWithResultHandling } from "@overleaf/mongo-utils/batchedUpdate.js"; import { db } from "./app/src/infrastructure/mongodb.js"; batchedUpdateWithResultHandling(db.systemmessages, { content: { $ne: "42" }}, { $set: { content: "42" } })',
],
{ encoding: 'utf-8' }
)
expect(
await db.systemmessages.find({}).project({ content: 1, _id: 0 }).toArray()
).to.deep.equal([{ content: '42' }, { content: '42' }, { content: '42' }])
expect(stderr).to.include(
'Completed batch ending 300000000000000000000000 (1995-07-09T16:12:48.000Z)'
)
expect(stderr).to.include(
`Completed batch ending ${edge} (1995-08-09T16:12:48.000Z)`
) // hit the edge
expect(stderr).to.include(
'Completed batch ending 400000000000000000000000 (2004-01-10T13:37:04.000Z)'
)
})
it('can handle ids sitting on the edge descending', async function () {
const edge = '3fd721800000000000000000'
await db.systemmessages.insertOne({
content: '1',
_id: new ObjectId('300000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '2',
_id: new ObjectId(edge),
})
await db.systemmessages.insertOne({
content: '3',
_id: new ObjectId('400000000000000000000000'),
})
const { stderr } = spawnSync(
process.argv0,
[
'--input-type=module',
'-e',
'import { batchedUpdateWithResultHandling } from "@overleaf/mongo-utils/batchedUpdate.js"; import { db } from "./app/src/infrastructure/mongodb.js"; batchedUpdateWithResultHandling(db.systemmessages, { content: { $ne: "42" }}, { $set: { content: "42" } })',
],
{
encoding: 'utf-8',
env: {
...process.env,
BATCH_DESCENDING: 'true',
BATCH_RANGE_START: '400000000000000000000001',
},
}
)
expect(
await db.systemmessages.find({}).project({ content: 1, _id: 0 }).toArray()
).to.deep.equal([{ content: '42' }, { content: '42' }, { content: '42' }])
expect(stderr).to.include(
'Completed batch ending 400000000000000000000000 (2004-01-10T13:37:04.000Z)'
)
expect(stderr).to.include(
`Completed batch ending ${edge} (2003-12-10T13:37:04.000Z)`
) // hit the edge
expect(stderr).to.include(
'Completed batch ending 300000000000000000000000 (1995-07-09T16:12:48.000Z)'
)
})
it('can handle dates as input', async function () {
await db.systemmessages.insertOne({
content: '1',
_id: new ObjectId('500000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '2',
_id: new ObjectId('400000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '3',
_id: new ObjectId('600000000000000000000000'),
})
await db.systemmessages.insertOne({
content: '4',
_id: new ObjectId('300000000000000000000000'),
})
spawnSync(
process.argv0,
[
'--input-type=module',
'-e',
'import { batchedUpdateWithResultHandling } from "@overleaf/mongo-utils/batchedUpdate.js"; import { db } from "./app/src/infrastructure/mongodb.js"; batchedUpdateWithResultHandling(db.systemmessages, { content: { $ne: "42" }}, { $set: { content: "42" } })',
],
{
env: {
...process.env,
BATCH_RANGE_START: '2004-01-10T13:37:03.000Z',
BATCH_RANGE_END: '2012-07-13T11:01:20.000Z',
},
}
)
await expect(
db.systemmessages.find({}).project({ content: 1, _id: 0 }).toArray()
).to.eventually.deep.equal([
{ content: '42' },
{ content: '42' },
{ content: '3' },
{ content: '4' },
])
})
})

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai'
import UserHelper from '../src/helpers/UserHelper.mjs'
describe('BetaProgram', function () {
let email, userHelper
beforeEach(async function () {
userHelper = new UserHelper()
email = userHelper.getDefaultEmail()
userHelper = await UserHelper.createUser({ email })
userHelper = await UserHelper.loginUser({
email,
password: userHelper.getDefaultPassword(),
})
})
it('should opt in', async function () {
const response = await userHelper.fetch('/beta/opt-in', { method: 'POST' })
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/beta/participate').toString()
)
const user = (await UserHelper.getUser({ email })).user
expect(user.betaProgram).to.equal(true)
})
it('should opt out', async function () {
const response = await userHelper.fetch('/beta/opt-out', { method: 'POST' })
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/beta/participate').toString()
)
const user = (await UserHelper.getUser({ email })).user
expect(user.betaProgram).to.equal(false)
})
})

View File

@@ -0,0 +1,87 @@
import Settings from '@overleaf/settings'
import request from './helpers/request.js'
// create a string that is longer than the max allowed (as defined in Server.js)
const wayTooLongString = 'a'.repeat(Settings.max_json_request_size + 1)
describe('BodyParserErrors', function () {
describe('when request is too large', function () {
describe('json', function () {
it('return 413', function (done) {
request.post(
{
url: '/login',
body: { password: wayTooLongString },
json: true,
},
(error, response, body) => {
if (error) {
return done(error)
}
response.statusCode.should.equal(413)
body.should.deep.equal({})
done()
}
)
})
})
describe('urlencoded', function () {
it('return 413', function (done) {
request.post(
{
url: '/login',
form: { password: wayTooLongString },
},
(error, response, body) => {
if (error) {
return done(error)
}
response.statusCode.should.equal(413)
body.should.match(/There was a problem with your request/)
done()
}
)
})
})
})
describe('when request is not too large', function () {
describe('json', function () {
it('return normal status code', function (done) {
request.post(
{
url: '/login',
body: { password: 'foo' },
json: true,
},
(error, response, body) => {
if (error) {
return done(error)
}
response.statusCode.should.equal(403)
done()
}
)
})
})
describe('urlencoded', function () {
it('return normal status code', function (done) {
request.post(
{
url: '/login',
form: { password: 'foo' },
},
(error, response, body) => {
if (error) {
return done(error)
}
response.statusCode.should.equal(403)
done()
}
)
})
})
})
})

View File

@@ -0,0 +1,97 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import MetricsHelper from './helpers/metrics.mjs'
const User = UserHelper.promises
const getMetric = MetricsHelper.promises.getMetric
describe('CDNMigration', function () {
let anon, user
beforeEach(async function () {
anon = new User()
user = new User()
await user.login()
})
let noCdnPreLogin, noCdnLoggedIn
let cdnBlockedTruePreLogin, cdnBlockedTrueLoggedIn
let cdnBlockedFalsePreLogin, cdnBlockedFalseLoggedIn
async function getNoCdn(path) {
return await getMetric(
line => line.includes('no_cdn') && line.includes(path)
)
}
async function getCdnBlocked(path, method) {
return await getMetric(
line =>
line.includes('cdn_blocked') &&
line.includes(`path="${path}"`) &&
line.includes(`method="${method}"`)
)
}
beforeEach(async function () {
noCdnPreLogin = await getNoCdn('pre-login')
noCdnLoggedIn = await getNoCdn('logged-in')
cdnBlockedTruePreLogin = await getCdnBlocked('pre-login', 'true')
cdnBlockedTrueLoggedIn = await getCdnBlocked('logged-in', 'true')
cdnBlockedFalsePreLogin = await getCdnBlocked('pre-login', 'false')
cdnBlockedFalseLoggedIn = await getCdnBlocked('logged-in', 'false')
})
describe('pre-login', function () {
it('should collect no_cdn', async function () {
await anon.doRequest('GET', '/login?nocdn=true')
expect(await getNoCdn('pre-login')).to.equal(noCdnPreLogin + 1)
})
it('should collect cdn_blocked', async function () {
await anon.doRequest('GET', '/login')
await anon.doRequest('GET', '/login')
await anon.doRequest('GET', '/login')
expect(await getCdnBlocked('pre-login', 'false')).to.equal(
cdnBlockedFalsePreLogin + 3
)
expect(await getCdnBlocked('pre-login', 'true')).to.equal(
cdnBlockedTruePreLogin
)
})
it('should collect cdn_blocked after nocdn', async function () {
await anon.doRequest('GET', '/login?nocdn=true')
await anon.doRequest('GET', '/login')
expect(await getCdnBlocked('pre-login', 'false')).to.equal(
cdnBlockedFalsePreLogin
)
expect(await getCdnBlocked('pre-login', 'true')).to.equal(
cdnBlockedTruePreLogin + 2
)
})
})
describe('logged-in', function () {
it('should collect no_cdn', async function () {
await user.doRequest('GET', '/project?nocdn=true')
expect(await getNoCdn('logged-in')).to.equal(noCdnLoggedIn + 1)
})
it('should collect cdn_blocked=false before nocdn', async function () {
await user.doRequest('GET', '/project')
await user.doRequest('GET', '/project')
await user.doRequest('GET', '/project')
expect(await getCdnBlocked('logged-in', 'false')).to.equal(
cdnBlockedFalseLoggedIn + 3
)
expect(await getCdnBlocked('logged-in', 'true')).to.equal(
cdnBlockedTrueLoggedIn
)
})
it('should collect cdn_blocked=true after nocdn=true', async function () {
await user.doRequest('GET', '/project?nocdn=true')
await user.doRequest('GET', '/project')
expect(await getCdnBlocked('logged-in', 'false')).to.equal(
cdnBlockedFalseLoggedIn
)
expect(await getCdnBlocked('logged-in', 'true')).to.equal(
cdnBlockedTrueLoggedIn + 2
)
})
})
})

View File

@@ -0,0 +1,261 @@
import { db } from '../../../app/src/infrastructure/mongodb.js'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs'
const User = UserHelper.promises
let MockHaveIBeenPwnedApi
before(function () {
MockHaveIBeenPwnedApi = MockHaveIBeenPwnedApiClass.instance()
})
describe('Captcha', function () {
let user
beforeEach('create user', async function () {
user = new User()
await user.ensureUserExists()
})
async function login(email, password, captchaResponse) {
await user.getCsrfToken()
return user.doRequest('POST', {
url: '/login',
json: {
email,
password,
'g-recaptcha-response': captchaResponse,
},
})
}
async function loginWithCaptcha(captchaResponse) {
return login(user.email, user.password, captchaResponse)
}
async function loginWithEmailAndCaptcha(email, captchaResponse) {
return login(email, user.password, captchaResponse)
}
async function canSkipCaptcha(email) {
await user.getCsrfToken()
const { response, body } = await user.doRequest('POST', {
url: '/login/can-skip-captcha',
json: { email },
})
expect(response.statusCode).to.equal(200)
return body
}
function expectBadCaptchaResponse(response, body) {
expect(response.statusCode).to.equal(400)
expect(body.errorReason).to.equal('cannot_verify_user_not_robot')
}
function expectSuccessfulLogin(response, body) {
expect(response.statusCode).to.equal(200)
expect(body).to.deep.equal({ redir: '/project' })
}
function expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
) {
expect(response.statusCode).to.equal(200)
expect(body).to.deep.equal({ redir: '/compromised-password' })
}
function expectBadLogin(response, body) {
expect(response.statusCode).to.equal(401)
expect(body).to.deep.equal({
message: {
type: 'error',
key: 'invalid-password-retry-or-reset',
},
})
}
it('should reject a login without captcha response', async function () {
const { response, body } = await loginWithCaptcha('')
expectBadCaptchaResponse(response, body)
})
it('should reject a login with an invalid captcha response', async function () {
const { response, body } = await loginWithCaptcha('invalid')
expectBadCaptchaResponse(response, body)
})
it('should accept a login with a valid captcha response', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
it('should note the solved captcha in audit log', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
const auditLog = await user.getAuditLog()
expect(auditLog[0].info).to.deep.equal({
captcha: 'solved',
method: 'Password login',
fromKnownDevice: false,
})
})
describe('deviceHistory', function () {
beforeEach('login', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
it('should be able to skip captcha with the same email', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(true)
})
it('should be able to omit captcha with the same email', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLogin(response, body)
})
it('should note the skipped captcha in audit log', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLogin(response, body)
const auditLog = await user.getAuditLog()
expect(auditLog[1].info).to.deep.equal({
captcha: 'skipped',
method: 'Password login',
fromKnownDevice: true,
})
})
it('should request a captcha for another email', async function () {
expect(await canSkipCaptcha('a@bc.de')).to.equal(false)
})
it('should flag missing captcha for another email', async function () {
const { response, body } = await loginWithEmailAndCaptcha('a@bc.de', '')
expectBadCaptchaResponse(response, body)
})
describe('login failure', function () {
beforeEach(async function () {
const { response, body } = await login(
user.email,
'bad password',
'valid'
)
expectBadLogin(response, body)
})
it('should be able to skip captcha per device history', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(true)
})
it('should request a captcha despite device history entry', async function () {
const { response, body } = await loginWithCaptcha('')
expectBadCaptchaResponse(response, body)
})
it('should accept the login with captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
describe('when the login failure happened a long time ago', function () {
beforeEach(async function () {
db.users.updateOne(
{ email: user.email },
{
$set: {
lastFailedLogin: new Date(
Date.now() - 90 * 24 * 60 * 60 * 1000
),
},
}
)
})
it('should be able to skip captcha per device history', async function () {
expect(await canSkipCaptcha(user.email)).to.equal(true)
})
it('should accept the login without captcha', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLogin(response, body)
})
it('should accept the login with captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
})
})
describe('cycle history', function () {
beforeEach('create and login with 10 other users', async function () {
for (let i = 0; i < 10; i++) {
const otherUser = new User()
otherUser.password = user.password
await otherUser.ensureUserExists()
const { response, body } = await loginWithEmailAndCaptcha(
otherUser.email,
'valid'
)
expectSuccessfulLogin(response, body)
}
})
it('should have rolled out the initial users email', async function () {
const { response, body } = await loginWithCaptcha('')
expectBadCaptchaResponse(response, body)
})
})
describe('HIBP', function () {
before(function () {
Settings.apis.haveIBeenPwned.enabled = true
})
after(function () {
Settings.apis.haveIBeenPwned.enabled = false
})
beforeEach(async function () {
user = new User()
user.password = 'aLeakedPassword42'
await user.ensureUserExists()
})
beforeEach('login to populate deviceHistory', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
})
beforeEach(function () {
// echo -n aLeakedPassword42 | sha1sum
MockHaveIBeenPwnedApi.addPasswordByHash(
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
)
})
it('should be able to skip HIBP check with deviceHistory and valid captcha', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
)
})
it('should be able to skip HIBP check with deviceHistory and skipped captcha', async function () {
const { response, body } = await loginWithCaptcha('')
expectSuccessfulLoginWithRedirectToCompromisedPasswordPage(
response,
body
)
})
it('should not be able to skip HIBP check without deviceHistory', async function () {
user.resetCookies()
const { response, body } = await loginWithCaptcha('valid')
expect(response.statusCode).to.equal(400)
expect(body.message.key).to.equal('password-compromised')
})
})
})
})

View File

@@ -0,0 +1,155 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { ObjectId, db } from '../../../app/src/infrastructure/mongodb.js'
import fs from 'node:fs/promises'
import UserHelper from './helpers/User.mjs'
import UserGetter from '../../../app/src/Features/User/UserGetter.js'
const User = UserHelper.promises
const TEST_FILE_PATH = '/tmp/test-users.txt'
describe('ClearSessionsSetMustReconfirm', function () {
let user1, user2, user3, user4, usersMustReconfirm, usersMustNotReconfirm
beforeEach('create test users', async function () {
user1 = new User()
user2 = new User() // not in the file
user3 = new User() // not in the file
user4 = new User()
await user1.login()
await user2.login()
await user3.login()
await user4.login()
usersMustReconfirm = [user1, user4]
usersMustNotReconfirm = [user2, user3]
})
beforeEach('create test file', async function () {
await fs.writeFile(
TEST_FILE_PATH,
usersMustReconfirm.map(user => user._id.toString()).join('\n')
)
})
afterEach('cleanup test file', async function () {
try {
await fs.unlink(TEST_FILE_PATH)
} catch (err) {
// Ignore error if file doesn't exist
}
})
async function runScript(filePath = TEST_FILE_PATH) {
let result
try {
result = await promisify(exec)(
['VERBOSE_LOGGING=true']
.concat(['node', 'scripts/clear_sessions_set_must_reconfirm.mjs'])
.concat([filePath])
.join(' ')
)
} catch (error) {
logger.error({ error }, 'script failed')
throw error
}
const { stdout: stdOut } = result
expect(stdOut).to.include('DONE.')
return result
}
describe('processing users', function () {
it('should process all users successfully', async function () {
const { stdout } = await runScript()
expect(stdout).to.include(`${usersMustReconfirm.length} successful`)
expect(stdout).to.include('0 failed to clear sessions')
expect(stdout).to.include('0 failed to set must_reconfirm')
for (const user of usersMustReconfirm) {
const updatedUser = await UserGetter.promises.getUser({
_id: user._id,
})
expect(updatedUser.must_reconfirm).to.be.true
}
for (const user of usersMustNotReconfirm) {
const updatedUser = await UserGetter.promises.getUser({
_id: user._id,
})
expect(updatedUser.must_reconfirm).to.be.false
}
})
it('should handle invalid user IDs in file', async function () {
await fs.writeFile(
TEST_FILE_PATH,
[
'invalid-id',
...usersMustReconfirm.map(user => user._id.toString()).join('\n'),
].join('\n')
)
try {
await runScript()
expect.fail('Should have thrown error')
} catch (error) {
expect(error.message).to.include('user ID not valid')
}
})
it('should process large number of users with concurrency limit', async function () {
const manyUserIds = Array.from({ length: 15 }, () =>
new ObjectId().toString()
)
await fs.writeFile(TEST_FILE_PATH, manyUserIds.join('\n'))
const { stdout } = await runScript()
expect(stdout).to.include('15 successful')
})
})
describe('error handling', function () {
beforeEach('ensure test file exists', async function () {
await fs.writeFile(
TEST_FILE_PATH,
usersMustReconfirm.map(user => user._id.toString()).join('\n')
)
})
it('should report failed user updates', async function () {
await db.users.updateOne(
{ _id: user1._id },
{ $set: { must_reconfirm: null } }
)
const { stdout } = await runScript()
expect(stdout).to.include('failed to set must_reconfirm')
})
it('should handle missing input file', async function () {
try {
await runScript(['/non/existent/file'])
expect.fail('Should have thrown error')
} catch (error) {
expect(error.message).to.include('ENOENT')
}
})
})
describe('audit logging', function () {
it('should create audit log entries for processed users', async function () {
await runScript()
for (const user of usersMustReconfirm) {
const auditLogEntry = await user.getAuditLog()
expect(auditLogEntry).to.exist
expect(auditLogEntry[0].operation).to.equal('login')
expect(auditLogEntry[1].operation).to.equal('must-reset-password-set')
expect(auditLogEntry[1].initiatorId).to.be.undefined
expect(auditLogEntry[1].ipAddress).to.be.undefined
expect(auditLogEntry[1].info).to.deep.equal({ script: true })
}
for (const user of usersMustNotReconfirm) {
const auditLogEntry = await user.getAuditLog()
expect(auditLogEntry).to.exist
expect(auditLogEntry[0].operation).to.equal('login')
expect(auditLogEntry[1]).to.be.undefined
}
})
})
})

View File

@@ -0,0 +1,67 @@
/* eslint-disable
n/handle-callback-err,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import Settings from '@overleaf/settings'
import request from './helpers/request.js'
describe('siteIsOpen', function () {
describe('when siteIsOpen is default (true)', function () {
it('should get page', function (done) {
return request.get('/login', (error, response, body) => {
response.statusCode.should.equal(200)
return done()
})
})
})
describe('when siteIsOpen is false', function () {
beforeEach(function () {
return (Settings.siteIsOpen = false)
})
afterEach(function () {
return (Settings.siteIsOpen = true)
})
it('should return maintenance page', function (done) {
request.get('/login', (error, response, body) => {
response.statusCode.should.equal(503)
body.should.match(/is currently down for maintenance/)
done()
})
})
it('should return a plain text message for a json request', function (done) {
request.get('/some/route', { json: true }, (error, response, body) => {
response.statusCode.should.equal(503)
body.message.should.match(/maintenance/)
body.message.should.match(/status.example.com/)
done()
})
})
it('should return a 200 on / for load balancer health checks', function (done) {
request.get('/', (error, response, body) => {
response.statusCode.should.equal(200)
done()
})
})
it('should return a 200 on /status for readiness checks', function (done) {
request.get('/status', (error, response, body) => {
response.statusCode.should.equal(200)
done()
})
})
})
})

View File

@@ -0,0 +1,283 @@
import sinon from 'sinon'
import chai, { expect } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import sinonChai from 'sinon-chai'
import CollectPaypalPastDueInvoice from '../../../scripts/recurly/collect_paypal_past_due_invoice.mjs'
import RecurlyWrapper from '../../../app/src/Features/Subscription/RecurlyWrapper.js'
import OError from '@overleaf/o-error'
const { main } = CollectPaypalPastDueInvoice
chai.use(chaiAsPromised)
chai.use(sinonChai)
// from https://recurly.com/developers/api-v2/v2.21/#operation/listInvoices
const invoicesXml = invoiceIds => `
<invoices type="array">
${invoiceIds
.map(
invoiceId => `
<invoice href="https://your-subdomain.recurly.com/v2/invoices/${invoiceId}">
<account href="https://your-subdomain.recurly.com/v2/accounts/${invoiceId}"/>
<subscriptions href="https://your-subdomain.recurly.com/v2/invoices/${invoiceId}/subscriptions"/>
<address>
<address1></address1>
<address2></address2>
<city></city>
<state></state>
<zip></zip>
<country></country>
<phone></phone>
</address>
<shipping_address>
<name>Lon Doner</name>
<address1>221B Baker St.</address1>
<address2></address2>
<city>London</city>
<state></state>
<zip>W1K 6AH</zip>
<country>GB</country>
<phone></phone>
</shipping_address>
<uuid>421f7b7d414e4c6792938e7c49d552e9</uuid>
<state>paid</state>
<invoice_number_prefix></invoice_number_prefix> <!-- Only populated for VAT Country Invoice Sequencing. Shows a country code. -->
<invoice_number type="integer">${invoiceId}</invoice_number>
<po_number nil="nil"></po_number>
<vat_number nil="nil"></vat_number>
<subtotal_in_cents type="integer">2000</subtotal_in_cents>
<discount_in_cents type="integer">0</discount_in_cents>
<due_on type="datetime">2018-01-30T21:11:50Z</due_on>
<balance_in_cents type="integer">0</balance_in_cents>
<type>charge</type>
<origin>purchase</origin>
<credit_invoices href="https://your-subdomain.recurly.com/v2/invoices/1325/credit_invoices"/>
<refundable_total_in_cents type="integer">2000</refundable_total_in_cents>
<credit_payments type="array">
</credit_payments>
<tax_in_cents type="integer">0</tax_in_cents>
<total_in_cents type="integer">1200</total_in_cents>
<currency>USD</currency>
<created_at type="datetime">2016-06-25T12:00:00Z</created_at>
<closed_at nil="nil"></closed_at>
<terms_and_conditions></terms_and_conditions>
<customer_notes></customer_notes>
<vat_reverse_charge_notes></vat_reverse_charge_notes>
<tax_type>usst</tax_type>
<tax_region>CA</tax_region>
<tax_rate type="float">0</tax_rate>
<net_terms type="integer">0</net_terms>
<collection_method>automatic</collection_method>
<redemptions href="https://your-subdomain.recurly.com/v2/invoices/e3f0a9e084a2468480d00ee61b090d4d/redemptions"/>
<line_items type="array">
<adjustment href="https://your-subdomain.recurly.com/v2/adjustments/05a4bbdeda2a47348185270021e6087b">
</adjustment>
</line_items>
<transactions type="array">
</transactions>
</invoice>`
)
.join('')}
</invoices>
`
// from https://recurly.com/developers/api-v2/v2.21/#operation/lookupAccountsBillingInfo
const billingInfoXml = `
<billing_info href="https://your-subdomain.recurly.com/v2/accounts/1/billing_info" type="credit_card">
<paypal_billing_agreement_id>PAYPAL_BILLING_AGREEMENT_ID</paypal_billing_agreement_id>
<account href="https://your-subdomain.recurly.com/v2/accounts/1"/>
<first_name>Verena</first_name>
<last_name>Example</last_name>
<company nil="nil"/>
<address1>123 Main St.</address1>
<address2 nil="nil"/>
<city>San Francisco</city>
<state>CA</state>
<zip>94105</zip>
<country>US</country>
<phone nil="nil"/>
<vat_number nil="nil"/>
<ip_address>127.0.0.1</ip_address>
<ip_address_country nil="nil"/>
<card_type>Visa</card_type>
<year type="integer">2019</year>
<month type="integer">11</month>
<first_six>411111</first_six>
<last_four>1111</last_four>
<updated_at type="datetime">2017-02-17T15:38:53Z</updated_at>
</billing_info>
`
// from https://recurly.com/developers/api-v2/v2.21/#operation/collectAnInvoice
const invoiceCollectXml = `
<invoice href="https://your-subdomain.recurly.com/v2/invoices/1000">
<account href="https://your-subdomain.recurly.com/v2/accounts/1"/>
<subscriptions href="https://your-subdomain.recurly.com/v2/invoices/1000/subscriptions"/>
<address>
<address1>123 Main St.</address1>
<address2 nil="nil"/>
<city>San Francisco</city>
<state>CA</state>
<zip>94105</zip>
<country>US</country>
<phone nil="nil"/>
</address>
<uuid>374a37924f83c733b9c9814e9580496a</uuid>
<state>pending</state>
<invoice_number_prefix/>
<invoice_number type="integer">1000</invoice_number>
<po_number nil="nil"/>
<vat_number nil="nil"/>
<subtotal_in_cents type="integer">5000</subtotal_in_cents>
<tax_in_cents type="integer">438</tax_in_cents>
<total_in_cents type="integer">5438</total_in_cents>
<currency>USD</currency>
<created_at type="datetime">2016-07-11T19:25:57Z</created_at>
<updated_at type="datetime">2016-07-11T19:25:57Z</updated_at>
<closed_at nil="nil"/>
<terms_and_conditions nil="nil"/>
<customer_notes nil="nil"/>
<tax_type>usst</tax_type>
<tax_region>CA</tax_region>
<tax_rate type="float">0.0875</tax_rate>
<net_terms type="integer">0</net_terms>
<collection_method>automatic</collection_method>
<line_items type="array">
<adjustment href="https://your-subdomain.recurly.com/v2/adjustments/374a2729397882fafbc82041a0a4dd0d" type="charge">
<!-- Detail. -->
</adjustment>
</line_items>
<transactions type="array">
</transactions>
<a name="mark_successful" href="https://your-subdomain.recurly.com/v2/invoices/1000/mark_successful" method="put"/>
<a name="mark_failed" href="https://your-subdomain.recurly.com/v2/invoices/1000/mark_failed" method="put"/>
</invoice>
`
const ITEMS_PER_PAGE = 3
const getInvoicePage = fullInvoicesIds => queryOptions => {
const cursor = queryOptions.qs.cursor
const startEnd = cursor?.split(':').map(Number) || []
const start = startEnd[0] || 0
const end = startEnd[1] || ITEMS_PER_PAGE
const body = invoicesXml(fullInvoicesIds.slice(start, end))
const hasMore = end < fullInvoicesIds.length
const nextPageCursor = hasMore ? `${end}%3A${end + ITEMS_PER_PAGE}&v=2` : null
const response = {
status: 200,
headers: {
link: hasMore
? `https://fakerecurly.com/v2/invoices?cursor=${nextPageCursor}`
: undefined,
},
}
return { response, body }
}
describe('CollectPayPalPastDueInvoice', function () {
let apiRequestStub
const fakeApiRequests = invoiceIds => {
apiRequestStub = sinon.stub(RecurlyWrapper.promises, 'apiRequest')
apiRequestStub.callsFake(options => {
if (options.url === 'invoices') {
return getInvoicePage(invoiceIds)(options)
}
if (/accounts\/(\d+)\/billing_info/.test(options.url)) {
return {
response: { status: 200, headers: {} },
body: billingInfoXml,
}
}
if (/invoices\/(\d+)\/collect/.test(options.url)) {
const invoiceId = options.url.match(/invoices\/(\d+)\/collect/)[1]
if (invoiceId < 400) {
return {
response: { status: 200, headers: {} },
body: invoiceCollectXml,
}
}
throw new OError(`Recurly API returned with status code: 404`, {
statusCode: 404,
})
}
})
}
afterEach(function () {
apiRequestStub?.restore()
})
it('collects one valid invoice', async function () {
fakeApiRequests([200])
const r = await main()
expect(r).to.eql({
INVOICES_COLLECTED: [200],
INVOICES_COLLECTED_SUCCESS: [200],
USERS_COLLECTED: ['200'],
})
})
it('collects several pages', async function () {
// 10 invoices, from 200 to 209
fakeApiRequests([...Array(10).keys()].map(i => i + 200))
const r = await main()
expect(r).to.eql({
INVOICES_COLLECTED: [200, 201, 202, 203, 204, 205, 206, 207, 208, 209],
INVOICES_COLLECTED_SUCCESS: [
200, 201, 202, 203, 204, 205, 206, 207, 208, 209,
],
USERS_COLLECTED: [
'200',
'201',
'202',
'203',
'204',
'205',
'206',
'207',
'208',
'209',
],
})
// 4 calls to get the invoices
// 10 calls to get the billing info
// 10 calls to collect the invoices
expect(apiRequestStub.callCount).to.eql(24)
})
it("resolves when no invoices are processed so we don't fail in staging", async function () {
fakeApiRequests([404])
const r = await main()
expect(r).to.eql({
INVOICES_COLLECTED: [404],
INVOICES_COLLECTED_SUCCESS: [],
USERS_COLLECTED: ['404'],
})
})
it('doesnt reject when there are no invoices', async function () {
fakeApiRequests([])
const r = await main()
expect(r).to.eql({
INVOICES_COLLECTED: [],
INVOICES_COLLECTED_SUCCESS: [],
USERS_COLLECTED: [],
})
})
it("resolves when collection is partially successful so we don't fail in prod", async function () {
fakeApiRequests([200, 404])
const r = await main()
expect(r).to.eql({
INVOICES_COLLECTED: [200, 404],
INVOICES_COLLECTED_SUCCESS: [200],
USERS_COLLECTED: ['200', '404'],
})
})
})

View File

@@ -0,0 +1,208 @@
import { expect } from 'chai'
import { exec } from 'node:child_process'
import mongodb from 'mongodb-legacy'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
const ObjectId = mongodb.ObjectId
describe('ConvertArchivedState', function () {
let userOne, userTwo, userThree, userFour
let projectOne, projectOneId
let projectTwo, projectTwoId
let projectThree, projectThreeId
let projectFour, projectFourId
let projectIdTrashed
let projectIdNotTrashed
let projectIdArchivedAndTrashed
let projectIdNotArchivedNotTrashed
beforeEach(async function () {
userOne = new User()
userTwo = new User()
userThree = new User()
userFour = new User()
await userOne.login()
await userTwo.login()
await userThree.login()
await userFour.login()
projectOneId = await userOne.createProject('old-archived-1', {
template: 'blank',
})
projectOne = await userOne.getProject(projectOneId)
projectOne.archived = true
projectOne.collaberator_refs.push(userTwo._id)
projectOne.tokenAccessReadOnly_refs.push(userThree._id)
await userOne.saveProject(projectOne)
projectTwoId = await userOne.createProject('old-archived-2', {
template: 'blank',
})
projectTwo = await userOne.getProject(projectTwoId)
projectTwo.archived = true
projectTwo.tokenAccessReadAndWrite_refs.push(userThree._id)
projectTwo.tokenAccessReadOnly_refs.push(userFour._id)
await userOne.saveProject(projectTwo)
projectThreeId = await userOne.createProject('already-new-archived', {
template: 'blank',
})
projectThree = await userOne.getProject(projectThreeId)
projectThree.archived = [
new ObjectId(userOne._id),
new ObjectId(userTwo._id),
new ObjectId(userFour._id),
]
projectThree.collaberator_refs.push(userTwo._id)
projectThree.tokenAccessReadOnly_refs.push(userFour._id)
await userOne.saveProject(projectThree)
projectFourId = await userOne.createProject('not-archived', {
template: 'blank',
})
projectFour = await userOne.getProject(projectFourId)
projectFour.archived = false
await userOne.saveProject(projectFour)
projectIdTrashed = await userOne.createProject('trashed', {
template: 'blank',
})
{
const p = await userOne.getProject(projectIdTrashed)
p.trashed = true
p.collaberator_refs.push(userTwo._id)
await userOne.saveProject(p)
}
projectIdNotTrashed = await userOne.createProject('not-trashed', {
template: 'blank',
})
{
const p = await userOne.getProject(projectIdNotTrashed)
p.trashed = false
p.collaberator_refs.push(userTwo._id)
await userOne.saveProject(p)
}
projectIdArchivedAndTrashed = await userOne.createProject('not-trashed', {
template: 'blank',
})
{
const p = await userOne.getProject(projectIdArchivedAndTrashed)
p.archived = true
p.trashed = true
p.collaberator_refs.push(userTwo._id)
await userOne.saveProject(p)
}
projectIdNotArchivedNotTrashed = await userOne.createProject(
'not-archived,not-trashed',
{
template: 'blank',
}
)
{
const p = await userOne.getProject(projectIdNotArchivedNotTrashed)
p.archived = false
p.trashed = false
p.collaberator_refs.push(userTwo._id)
await userOne.saveProject(p)
}
})
beforeEach(function (done) {
exec(
'CONNECT_DELAY=1 node scripts/convert_archived_state.mjs FIRST,SECOND',
error => {
if (error) {
return done(error)
}
done()
}
)
})
describe('main method', function () {
it('should change a project archived boolean to an array', async function () {
projectOne = await userOne.getProject(projectOneId)
projectTwo = await userOne.getProject(projectTwoId)
expect(convertObjectIdsToStrings(projectOne.archived)).to.deep.equal([
userOne._id,
userTwo._id,
userThree._id,
])
expect(convertObjectIdsToStrings(projectTwo.archived)).to.deep.equal([
userOne._id,
userThree._id,
userFour._id,
])
expect(projectTwo.trashed).to.deep.equal([])
})
it('should not change the value of a project already archived with an array', async function () {
projectThree = await userOne.getProject(projectThreeId)
expect(convertObjectIdsToStrings(projectThree.archived)).to.deep.equal([
userOne._id,
userTwo._id,
userFour._id,
])
expect(projectThree.trashed).to.deep.equal([])
})
it('should change a none-archived project with a boolean value to an array', async function () {
projectFour = await userOne.getProject(projectFourId)
expect(convertObjectIdsToStrings(projectFour.archived)).to.deep.equal([])
expect(projectFour.trashed).to.deep.equal([])
})
it('should change a archived and trashed project with a boolean value to an array', async function () {
const p = await userOne.getProject(projectIdArchivedAndTrashed)
expect(convertObjectIdsToStrings(p.archived)).to.deep.equal([
userOne._id,
userTwo._id,
])
expect(convertObjectIdsToStrings(p.trashed)).to.deep.equal([
userOne._id,
userTwo._id,
])
})
it('should change a trashed project with a boolean value to an array', async function () {
const p = await userOne.getProject(projectIdTrashed)
expect(p.archived).to.not.exist
expect(convertObjectIdsToStrings(p.trashed)).to.deep.equal([
userOne._id,
userTwo._id,
])
})
it('should change a not-trashed project with a boolean value to an array', async function () {
const p = await userOne.getProject(projectIdNotTrashed)
expect(p.archived).to.not.exist
expect(convertObjectIdsToStrings(p.trashed)).to.deep.equal([])
})
it('should change a not-archived/not-trashed project with a boolean value to an array', async function () {
const p = await userOne.getProject(projectIdNotArchivedNotTrashed)
expect(p.archived).to.deep.equal([])
expect(p.trashed).to.deep.equal([])
})
})
function convertObjectIdsToStrings(ids) {
if (typeof ids === 'object') {
return ids.map(id => {
return id.toString()
})
}
}
})

View File

@@ -0,0 +1,258 @@
import Settings from '@overleaf/settings'
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import MetricsHelper from './helpers/metrics.mjs'
import cookieSignature from 'cookie-signature'
const User = UserHelper.promises
const getMetric = MetricsHelper.promises.getMetric
const resetMetrics = MetricsHelper.resetMetrics
async function getSessionCookieMetric(status) {
return getMetric(
line =>
line.includes('session_cookie') && line.includes(`status="${status}"`)
)
}
/*
* Modifies the session cookie by removing the existing signature and signing
* the cookie with a new secret.
*/
function modifyCookieSignature(originalCookie, newSecret) {
const [sessionKey] = originalCookie.value.slice(2).split('.')
return cookieSignature.sign(sessionKey, newSecret)
}
describe('Session cookie', function () {
before(async function () {
this.user = new User()
})
describe('with no session cookie', function () {
before(async function () {
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should accept the request', function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return a signed cookie', async function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
expect(cookie.value).to.match(/^s:/)
})
it('should sign the cookie with the current session secret', function () {
const cookie = this.user.sessionCookie()
const unsigned = cookieSignature.unsign(
cookie.value.slice(2), // strip the 's:' prefix
Settings.security.sessionSecret
)
expect(unsigned).not.to.be.false
expect(unsigned).to.match(/^[a-zA-Z0-9_-]+$/)
})
it('should record a "none" cookie metric', async function () {
const count = await getSessionCookieMetric('none')
expect(count).to.equal(1)
})
})
describe('with a signed session cookie', function () {
before(async function () {
// get the first cookie
await this.user.doRequest('GET', '/login')
this.firstCookie = this.user.sessionCookie()
// make a subsequent request
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should accept the request', function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return the same signed cookie', async function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
expect(cookie.value).to.equal(this.firstCookie.value)
})
it('should record a "signed" cookie metric', async function () {
const count = await getSessionCookieMetric('signed')
expect(count).to.equal(1)
})
})
describe('with a session cookie signed with the fallback session secret', function () {
before(async function () {
// get the first cookie
await this.user.doRequest('GET', '/login')
this.firstCookie = this.user.sessionCookie()
// sign the session key with the fallback secret
this.user.setSessionCookie(
's:' +
modifyCookieSignature(
this.firstCookie,
Settings.security.sessionSecretFallback
)
)
// make a subsequent request
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should accept the request', async function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return the cookie signed with the current secret', function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
expect(cookie.value).to.equal(this.firstCookie.value)
})
it('should record a "signed" cookie metric', async function () {
const count = await getSessionCookieMetric('signed')
expect(count).to.equal(1)
})
})
describe('with a session cookie signed with the upcoming session secret', function () {
before(async function () {
// get the first cookie
await this.user.doRequest('GET', '/login')
this.firstCookie = this.user.sessionCookie()
// sign the session key with the upcoming secret
this.user.setSessionCookie(
's:' +
modifyCookieSignature(
this.firstCookie,
Settings.security.sessionSecretUpcoming
)
)
// make a subsequent request
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should accept the request', async function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return the cookie signed with the current secret', function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
expect(cookie.value).to.equal(this.firstCookie.value)
})
it('should record a "signed" cookie metric', async function () {
const count = await getSessionCookieMetric('signed')
expect(count).to.equal(1)
})
})
describe('with a session cookie signed with an invalid secret', function () {
before(async function () {
// get the first cookie
await this.user.doRequest('GET', '/login')
this.firstCookie = this.user.sessionCookie()
// sign the session key with an invalid secret
this.user.setSessionCookie(
's:' + modifyCookieSignature(this.firstCookie, 'invalid-secret')
)
// make a subsequent request
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should not reject the request', async function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return a new cookie signed with the current secret', function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
const [sessionKey] = cookie.value.slice(2).split('.')
expect(sessionKey).not.to.equal(this.firstSessionKey)
})
it('should record a "bad-signature" cookie metric', async function () {
const count = await getSessionCookieMetric('bad-signature')
expect(count).to.equal(1)
})
})
describe('with an unsigned session cookie', function () {
before(async function () {
// get the first cookie
await this.user.doRequest('GET', '/login')
this.firstCookie = this.user.sessionCookie()
// use the session key without signing it
const [sessionKey] = this.firstCookie.value.slice(2).split('.')
this.firstSessionKey = sessionKey
this.user.setSessionCookie(sessionKey)
// make a subsequent request
resetMetrics()
const { response } = await this.user.doRequest('GET', '/login')
this.response = response
})
after(function () {
this.user.resetCookies()
})
it('should not reject the request', async function () {
expect(this.response.statusCode).to.equal(200)
})
it('should return a new cookie signed with the current secret', function () {
const cookie = this.user.sessionCookie()
expect(cookie).to.exist
expect(cookie.key).to.equal(Settings.cookieName)
const [sessionKey] = cookie.value.slice(2).split('.')
expect(sessionKey).not.to.equal(this.firstSessionKey)
})
it('should record an "unsigned" cookie metric', async function () {
const count = await getSessionCookieMetric('unsigned')
expect(count).to.equal(1)
})
})
})

View File

@@ -0,0 +1,263 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
const ONE_DAY_IN_S = 60 * 60 * 24
const BATCH_SIZE = 3
function getSecondsFromObjectId(id) {
return id.getTimestamp().getTime() / 1000
}
function getObjectIdFromDate(date) {
const seconds = new Date(date).getTime() / 1000
return ObjectId.createFromTime(seconds)
}
describe('DeleteOrphanedDocsOnlineCheck', function () {
let docIds
let projectIds
let stopAtSeconds
let BATCH_LAST_ID
beforeEach('create docs', async function () {
BATCH_LAST_ID = getObjectIdFromDate('2021-03-31T00:00:00.000Z')
docIds = []
docIds[0] = getObjectIdFromDate('2021-04-01T00:00:00.000Z')
docIds[1] = getObjectIdFromDate('2021-04-02T00:00:00.000Z')
docIds[2] = getObjectIdFromDate('2021-04-11T00:00:00.000Z')
docIds[3] = getObjectIdFromDate('2021-04-12T00:00:00.000Z')
docIds[4] = getObjectIdFromDate('2021-04-13T00:00:00.000Z')
docIds[5] = getObjectIdFromDate('2021-04-14T00:00:00.000Z')
docIds[6] = getObjectIdFromDate('2021-04-15T00:00:00.000Z')
docIds[7] = getObjectIdFromDate('2021-04-16T00:01:00.000Z')
docIds[8] = getObjectIdFromDate('2021-04-16T00:02:00.000Z')
docIds[9] = getObjectIdFromDate('2021-04-16T00:03:00.000Z')
docIds[10] = getObjectIdFromDate('2021-04-16T00:04:00.000Z')
docIds[11] = getObjectIdFromDate('2021-04-16T00:05:00.000Z')
projectIds = []
projectIds[0] = getObjectIdFromDate('2021-04-01T00:00:00.000Z')
projectIds[1] = getObjectIdFromDate('2021-04-02T00:00:00.000Z')
projectIds[2] = getObjectIdFromDate('2021-04-11T00:00:00.000Z')
projectIds[3] = getObjectIdFromDate('2021-04-12T00:00:00.000Z')
projectIds[4] = getObjectIdFromDate('2021-04-13T00:00:00.000Z')
projectIds[5] = getObjectIdFromDate('2021-04-14T00:00:00.000Z')
projectIds[6] = getObjectIdFromDate('2021-04-15T00:00:00.000Z')
projectIds[7] = getObjectIdFromDate('2021-04-16T00:01:00.000Z')
projectIds[8] = getObjectIdFromDate('2021-04-16T00:02:00.000Z')
projectIds[9] = getObjectIdFromDate('2021-04-16T00:03:00.000Z')
// two docs in the same project
projectIds[10] = projectIds[9]
projectIds[11] = projectIds[4]
stopAtSeconds = new Date('2021-04-17T00:00:00.000Z').getTime() / 1000
})
beforeEach('create doc stubs', async function () {
await db.docs.insertMany([
// orphaned
{ _id: docIds[0], project_id: projectIds[0] },
{ _id: docIds[1], project_id: projectIds[1] },
{ _id: docIds[2], project_id: projectIds[2] },
{ _id: docIds[3], project_id: projectIds[3] },
// orphaned, failed hard deletion
{ _id: docIds[4], project_id: projectIds[4] },
// not orphaned, live
{ _id: docIds[5], project_id: projectIds[5] },
// not orphaned, pending hard deletion
{ _id: docIds[6], project_id: projectIds[6] },
// multiple in a single batch
{ _id: docIds[7], project_id: projectIds[7] },
{ _id: docIds[8], project_id: projectIds[8] },
{ _id: docIds[9], project_id: projectIds[9] },
// two docs in one project
{ _id: docIds[10], project_id: projectIds[10] },
{ _id: docIds[11], project_id: projectIds[11] },
])
})
beforeEach('create project stubs', async function () {
await db.projects.insertMany([
// live
{ _id: projectIds[5] },
])
})
beforeEach('create deleted project stubs', async function () {
await db.deletedProjects.insertMany([
// hard-deleted
{ deleterData: { deletedProjectId: projectIds[4] } },
// soft-deleted
{
deleterData: { deletedProjectId: projectIds[6] },
project: { _id: projectIds[6] },
},
])
})
let options
async function runScript(dryRun) {
options = {
BATCH_LAST_ID,
BATCH_SIZE,
DRY_RUN: dryRun,
INCREMENT_BY_S: ONE_DAY_IN_S,
STOP_AT_S: stopAtSeconds,
// Lower concurrency to 1 for strict sequence of log messages.
READ_CONCURRENCY_SECONDARY: 1,
READ_CONCURRENCY_PRIMARY: 1,
WRITE_CONCURRENCY: 1,
// start right away
LET_USER_DOUBLE_CHECK_INPUTS_FOR: 1,
}
let result
try {
result = await promisify(exec)(
Object.entries(options)
.map(([key, value]) => `${key}=${value}`)
.concat([
// Hide verbose log messages `calling destroy for project in docstore`
'LOG_LEVEL=error',
// Hide deprecation warnings for calling `db.collection.count`
'NODE_OPTIONS=--no-deprecation',
])
.concat(['node', 'scripts/delete_orphaned_docs_online_check.mjs'])
.join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
let { stderr: stdErr, stdout: stdOut } = result
stdErr = stdErr.split('\n').filter(filterOutput)
stdOut = stdOut.split('\n').filter(filterOutput)
const oneDayFromProjectId9InSeconds =
getSecondsFromObjectId(projectIds[9]) + ONE_DAY_IN_S
const oneDayFromProjectId9AsObjectId = getObjectIdFromDate(
1000 * oneDayFromProjectId9InSeconds
)
expect(stdOut).to.deep.equal([
`Checking projects ["${projectIds[0]}"]`,
`Deleted project ${projectIds[0]} has 1 orphaned docs: ["${docIds[0]}"]`,
`Checking projects ["${projectIds[1]}"]`,
`Deleted project ${projectIds[1]} has 1 orphaned docs: ["${docIds[1]}"]`,
`Checking projects ["${projectIds[2]}"]`,
`Deleted project ${projectIds[2]} has 1 orphaned docs: ["${docIds[2]}"]`,
`Checking projects ["${projectIds[3]}"]`,
`Deleted project ${projectIds[3]} has 1 orphaned docs: ["${docIds[3]}"]`,
// Two docs in the same project
`Checking projects ["${projectIds[4]}"]`,
`Deleted project ${projectIds[4]} has 2 orphaned docs: ["${docIds[4]}","${docIds[11]}"]`,
// Project 5 is live
`Checking projects ["${projectIds[5]}"]`,
// Project 6 is soft-deleted
`Checking projects ["${projectIds[6]}"]`,
// 7,8,9 are on the same day, but exceed the batch size of 2
`Checking projects ["${projectIds[7]}","${projectIds[8]}","${projectIds[9]}"]`,
`Deleted project ${projectIds[7]} has 1 orphaned docs: ["${docIds[7]}"]`,
`Deleted project ${projectIds[8]} has 1 orphaned docs: ["${docIds[8]}"]`,
// Two docs in the same project
`Deleted project ${projectIds[9]} has 2 orphaned docs: ["${docIds[9]}","${docIds[10]}"]`,
])
expect(stdErr).to.deep.equal([
...`Options: ${JSON.stringify(options, null, 2)}`.split('\n'),
'Waiting for you to double check inputs for 1 ms',
`Processed 1 projects (1 projects with orphaned docs/1 docs deleted) until ${getObjectIdFromDate(
'2021-04-01T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-02T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-03T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-04T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-05T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-06T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-07T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-08T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-09T00:00:00.000Z'
)}`,
`Processed 2 projects (2 projects with orphaned docs/2 docs deleted) until ${getObjectIdFromDate(
'2021-04-10T00:00:00.000Z'
)}`,
`Processed 3 projects (3 projects with orphaned docs/3 docs deleted) until ${getObjectIdFromDate(
'2021-04-11T00:00:00.000Z'
)}`,
`Processed 4 projects (4 projects with orphaned docs/4 docs deleted) until ${getObjectIdFromDate(
'2021-04-12T00:00:00.000Z'
)}`,
`Processed 5 projects (5 projects with orphaned docs/6 docs deleted) until ${getObjectIdFromDate(
'2021-04-13T00:00:00.000Z'
)}`,
`Processed 6 projects (5 projects with orphaned docs/6 docs deleted) until ${getObjectIdFromDate(
'2021-04-14T00:00:00.000Z'
)}`,
`Processed 7 projects (5 projects with orphaned docs/6 docs deleted) until ${getObjectIdFromDate(
'2021-04-15T00:00:00.000Z'
)}`,
`Processed 7 projects (5 projects with orphaned docs/6 docs deleted) until ${getObjectIdFromDate(
'2021-04-16T00:00:00.000Z'
)}`,
// 7,8,9,10 are on the same day, but exceed the batch size of 3
// Project 9 has two docs.
`Processed 10 projects (8 projects with orphaned docs/10 docs deleted) until ${projectIds[9]}`,
// 10 has as ready been processed as part of the last batch -- same project_id as 9.
`Processed 10 projects (8 projects with orphaned docs/10 docs deleted) until ${oneDayFromProjectId9AsObjectId}`,
'Done.',
])
}
describe('DRY_RUN=true', function () {
beforeEach('run script', async function () {
await runScript(true)
})
it('should leave docs as is', async function () {
const docs = await db.docs.find({}).toArray()
expect(docs).to.deep.equal([
{ _id: docIds[0], project_id: projectIds[0] },
{ _id: docIds[1], project_id: projectIds[1] },
{ _id: docIds[2], project_id: projectIds[2] },
{ _id: docIds[3], project_id: projectIds[3] },
{ _id: docIds[4], project_id: projectIds[4] },
{ _id: docIds[5], project_id: projectIds[5] },
{ _id: docIds[6], project_id: projectIds[6] },
{ _id: docIds[7], project_id: projectIds[7] },
{ _id: docIds[8], project_id: projectIds[8] },
{ _id: docIds[9], project_id: projectIds[9] },
{ _id: docIds[10], project_id: projectIds[10] },
{ _id: docIds[11], project_id: projectIds[11] },
])
})
})
describe('DRY_RUN=false', function () {
beforeEach('run script', async function () {
await runScript(false)
})
it('should deleted all but docs from live/soft-deleted projects', async function () {
const docs = await db.docs.find({}).toArray()
expect(docs).to.deep.equal([
// not orphaned, live
{ _id: docIds[5], project_id: projectIds[5] },
// not orphaned, pending hard deletion
{ _id: docIds[6], project_id: projectIds[6] },
])
})
})
})

View File

@@ -0,0 +1,591 @@
import User from './helpers/User.mjs'
import Subscription from './helpers/Subscription.mjs'
import request from './helpers/request.js'
import async from 'async'
import { expect } from 'chai'
import settings from '@overleaf/settings'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import Features from '../../../app/src/infrastructure/Features.js'
import MockDocstoreApiClass from './mocks/MockDocstoreApi.mjs'
import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs'
import MockChatApiClass from './mocks/MockChatApi.mjs'
import MockGitBridgeApiClass from './mocks/MockGitBridgeApi.mjs'
import MockHistoryBackupDeletionApiClass from './mocks/MockHistoryBackupDeletionApi.mjs'
let MockDocstoreApi,
MockFilestoreApi,
MockChatApi,
MockGitBridgeApi,
MockHistoryBackupDeletionApi
before(function () {
MockDocstoreApi = MockDocstoreApiClass.instance()
MockFilestoreApi = MockFilestoreApiClass.instance()
MockChatApi = MockChatApiClass.instance()
MockGitBridgeApi = MockGitBridgeApiClass.instance()
MockHistoryBackupDeletionApi = MockHistoryBackupDeletionApiClass.instance()
})
describe('Deleting a user', function () {
beforeEach(function (done) {
async.auto(
{
user: cb => {
const user = new User()
user.ensureUserExists(() => {
cb(null, user)
})
},
login: [
'user',
(results, cb) => {
results.user.login(cb)
},
],
subscription: [
'user',
'login',
(results, cb) => {
const subscription = new Subscription({
admin_id: results.user._id,
})
subscription.ensureExists(err => {
cb(err, subscription)
})
},
],
},
(err, results) => {
expect(err).not.to.exist
this.user = results.user
this.subscription = results.subscription
done()
}
)
})
it('Should remove the user from active users', function (done) {
this.user.get((error, user) => {
expect(error).not.to.exist
expect(user).to.exist
this.user.deleteUser(error => {
expect(error).not.to.exist
this.user.get((error, user) => {
expect(error).not.to.exist
expect(user).not.to.exist
done()
})
})
})
})
it('Should create a soft-deleted user', function (done) {
this.user.get((error, user) => {
expect(error).not.to.exist
this.user.deleteUser(error => {
expect(error).not.to.exist
db.deletedUsers.findOne(
{ 'user._id': user._id },
(error, deletedUser) => {
expect(error).not.to.exist
expect(deletedUser).to.exist
// it should set the 'deleterData' correctly
expect(deletedUser.deleterData.deleterId.toString()).to.equal(
user._id.toString()
)
expect(deletedUser.deleterData.deletedUserId.toString()).to.equal(
user._id.toString()
)
expect(deletedUser.deleterData.deletedUserReferralId).to.equal(
user.referal_id
)
// it should set the 'user' correctly
expect(deletedUser.user._id.toString()).to.equal(
user._id.toString()
)
expect(deletedUser.user.email).to.equal(user.email)
done()
}
)
})
})
})
it("Should delete the user's projects", function (done) {
this.user.createProject('wombat', (error, projectId) => {
expect(error).not.to.exist
this.user.getProject(projectId, (error, project) => {
expect(error).not.to.exist
expect(project).to.exist
this.user.deleteUser(error => {
expect(error).not.to.exist
this.user.getProject(projectId, (error, project) => {
expect(error).not.to.exist
expect(project).not.to.exist
done()
})
})
})
})
})
describe('when scrubbing the user', function () {
beforeEach(function (done) {
this.user.get((error, user) => {
if (error) {
throw error
}
this.userId = user._id
this.user.deleteUser(done)
})
})
it('Should remove the user data from mongo', function (done) {
db.deletedUsers.findOne(
{ 'deleterData.deletedUserId': this.userId },
(error, deletedUser) => {
expect(error).not.to.exist
expect(deletedUser).to.exist
expect(deletedUser.deleterData.deleterIpAddress).to.exist
expect(deletedUser.user).to.exist
request.post(
`/internal/users/${this.userId}/expire`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(204)
db.deletedUsers.findOne(
{ 'deleterData.deletedUserId': this.userId },
(error, deletedUser) => {
expect(error).not.to.exist
expect(deletedUser).to.exist
expect(deletedUser.deleterData.deleterIpAddress).not.to.exist
expect(deletedUser.user).not.to.exist
done()
}
)
}
)
}
)
})
})
})
describe('Deleting a project', function () {
beforeEach(function (done) {
this.user = new User()
this.projectName = 'wombat'
this.user.ensureUserExists(() => {
this.user.login(() => {
this.user.createProject(this.projectName, (_e, projectId) => {
this.projectId = projectId
done()
})
})
})
})
it('Should remove the project from active projects', function (done) {
this.user.getProject(this.projectId, (error, project) => {
expect(error).not.to.exist
expect(project).to.exist
this.user.deleteProject(this.projectId, error => {
expect(error).not.to.exist
this.user.getProject(this.projectId, (error, project) => {
expect(error).not.to.exist
expect(project).not.to.exist
done()
})
})
})
})
it('Should create a soft-deleted project', function (done) {
this.user.getProject(this.projectId, (error, project) => {
expect(error).not.to.exist
this.user.get((error, user) => {
expect(error).not.to.exist
this.user.deleteProject(this.projectId, error => {
expect(error).not.to.exist
db.deletedProjects.findOne(
{ 'deleterData.deletedProjectId': project._id },
(error, deletedProject) => {
expect(error).not.to.exist
expect(deletedProject).to.exist
// it should set the 'deleterData' correctly
expect(deletedProject.deleterData.deleterId.toString()).to.equal(
user._id.toString()
)
expect(
deletedProject.deleterData.deletedProjectId.toString()
).to.equal(project._id.toString())
expect(
deletedProject.deleterData.deletedProjectOwnerId.toString()
).to.equal(user._id.toString())
// it should set the 'user' correctly
expect(deletedProject.project._id.toString()).to.equal(
project._id.toString()
)
expect(deletedProject.project.name).to.equal(this.projectName)
done()
}
)
})
})
})
})
describe('when the project has deleted files', function () {
beforeEach('get rootFolder id', function (done) {
this.user.getProject(this.projectId, (error, project) => {
if (error) return done(error)
this.rootFolder = project.rootFolder[0]._id
done()
})
})
let allFileIds
beforeEach('reset allFileIds', function () {
allFileIds = []
})
function createAndDeleteFile(name) {
let fileId
beforeEach(`create file ${name}`, function (done) {
this.user.uploadExampleFileInProject(
this.projectId,
this.rootFolder,
name,
(error, theFileId) => {
fileId = theFileId
allFileIds.push(theFileId)
done(error)
}
)
})
beforeEach(`delete file ${name}`, function (done) {
this.user.deleteItemInProject(this.projectId, 'file', fileId, done)
})
}
for (const name of ['a.png', 'another.png']) {
createAndDeleteFile(name)
}
it('should have two deleteFiles entries', async function () {
const files = await db.deletedFiles
.find({}, { sort: { _id: 1 } })
.toArray()
expect(files).to.have.length(2)
expect(files.map(file => file._id.toString())).to.deep.equal(allFileIds)
})
describe('When the deleted project is expired', function () {
beforeEach('soft delete the project', function (done) {
this.user.deleteProject(this.projectId, done)
})
beforeEach('hard delete the project', function (done) {
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
done()
}
)
})
it('should cleanup the deleteFiles', async function () {
const files = await db.deletedFiles
.find({}, { sort: { _id: 1 } })
.toArray()
expect(files).to.deep.equal([])
})
})
})
describe('When the project has docs', function () {
beforeEach(function (done) {
this.user.getProject(this.projectId, (error, project) => {
if (error) {
throw error
}
this.user.createDocInProject(
this.projectId,
project.rootFolder[0]._id,
'potato',
(error, docId) => {
if (error) {
throw error
}
this.docId = docId
done()
}
)
MockFilestoreApi.files[this.projectId.toString()] = {
dummyFile: 'wombat',
}
MockChatApi.projects[this.projectId.toString()] = ['message']
if (Features.hasFeature('git-bridge')) {
MockGitBridgeApi.projects[this.projectId.toString()] = {
data: 'some-data',
}
}
})
})
describe('When the deleted project is expired', function () {
beforeEach(function (done) {
this.user.deleteProject(this.projectId, error => {
if (error) {
throw error
}
done()
})
})
it('Should destroy the docs', function (done) {
expect(
MockDocstoreApi.docs[this.projectId.toString()][this.docId.toString()]
).to.exist
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
expect(MockDocstoreApi.docs[this.projectId.toString()]).not.to.exist
done()
}
)
})
it('Should destroy the files if filestore is in use', function (done) {
expect(MockFilestoreApi.files[this.projectId.toString()]).to.exist
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
if (Features.hasFeature('filestore')) {
expect(MockFilestoreApi.files[this.projectId.toString()]).not.to
.exist
} else {
// don't touch files in filestore if it's not in use
expect(MockFilestoreApi.files[this.projectId.toString()]).to.exist
}
done()
}
)
})
it('Should destroy the chat', function (done) {
expect(MockChatApi.projects[this.projectId.toString()]).to.exist
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
expect(MockChatApi.projects[this.projectId.toString()]).not.to.exist
done()
}
)
})
it('Should remove the project data from mongo', function (done) {
db.deletedProjects.findOne(
{ 'deleterData.deletedProjectId': new ObjectId(this.projectId) },
(error, deletedProject) => {
expect(error).not.to.exist
expect(deletedProject).to.exist
expect(deletedProject.project).to.exist
expect(deletedProject.deleterData.deleterIpAddress).to.exist
expect(deletedProject.deleterData.deletedAt).to.exist
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
db.deletedProjects.findOne(
{
'deleterData.deletedProjectId': new ObjectId(
this.projectId
),
},
(error, deletedProject) => {
expect(error).not.to.exist
expect(deletedProject).to.exist
expect(deletedProject.project).not.to.exist
expect(deletedProject.deleterData.deleterIpAddress).not.to
.exist
expect(deletedProject.deleterData.deletedAt).to.exist
done()
}
)
}
)
}
)
})
if (Features.hasFeature('saas')) {
it('Should destroy the history backup', function (done) {
MockHistoryBackupDeletionApi.prepareProject(this.projectId, 204)
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(200)
expect(
MockHistoryBackupDeletionApi.projects[this.projectId.toString()]
).not.to.exist
done()
}
)
})
it('Should abort when the history backup cannot be deleted', function (done) {
MockHistoryBackupDeletionApi.prepareProject(this.projectId, 422)
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
expect(error).not.to.exist
expect(res.statusCode).to.equal(500)
expect(
MockHistoryBackupDeletionApi.projects[this.projectId.toString()]
).to.exist
db.deletedProjects.findOne(
{
'deleterData.deletedProjectId': new ObjectId(this.projectId),
},
(error, deletedProject) => {
expect(error).not.to.exist
expect(deletedProject).to.exist
expect(deletedProject.project).to.exist
done()
}
)
}
)
})
}
})
})
if (Features.hasFeature('git-bridge')) {
describe('When the project has git-bridge data', function () {
beforeEach(function () {
MockGitBridgeApi.projects[this.projectId.toString()] = {
data: 'some-data',
}
})
describe('When the deleted project is expired', function () {
beforeEach(function (done) {
this.user.deleteProject(this.projectId, error => {
if (error) {
return done(error)
}
request.post(
`/internal/project/${this.projectId}/expire-deleted-project`,
{
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
},
(error, res) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
done()
}
)
})
})
it('should delete the git-bridge data', function () {
expect(MockGitBridgeApi.projects[this.projectId.toString()]).not.to
.exist
})
})
})
}
})

View File

@@ -0,0 +1,157 @@
import User from './helpers/User.mjs'
import request from './helpers/request.js'
import { expect } from 'chai'
import settings from '@overleaf/settings'
import mongodb from 'mongodb-legacy'
const ObjectId = mongodb.ObjectId
describe('DocUpdate', function () {
beforeEach(function (done) {
this.user = new User()
this.projectName = 'wombat'
this.user.ensureUserExists(() => {
this.user.login(() => {
this.user.createProject(this.projectName, (error, projectId) => {
if (error) return done(error)
this.projectId = projectId
this.user.getProject(this.projectId, (error, project) => {
if (error) return done(error)
this.project = project
this.user.createDocInProject(
this.projectId,
this.project.rootFolder[0]._id,
'potato',
(error, docId) => {
this.docId = docId
done(error)
}
)
})
})
})
})
})
function writeContent(
{ projectId, docId, lines, version, ranges, lastUpdatedAt, lastUpdatedBy },
callback
) {
request(
{
method: 'POST',
url: `/project/${projectId}/doc/${docId}`,
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
json: { lines, version, ranges, lastUpdatedAt, lastUpdatedBy },
},
(error, res) => {
if (error) return callback(error)
if (res.statusCode !== 200)
return callback(
new Error(`non-success statusCode: ${res.statusCode}`)
)
callback()
}
)
}
function updateContent(options, callback) {
writeContent(options, err => {
if (err) return callback(err)
options.lines.push('foo')
options.version++
writeContent(options, callback)
})
}
function writeContentTwice(options, callback) {
writeContent(options, err => {
if (err) return callback(err)
writeContent(options, callback)
})
}
let writeOptions
beforeEach(function () {
writeOptions = {
projectId: this.projectId,
docId: this.docId,
lines: ['a'],
version: 1,
ranges: {},
lastUpdatedAt: new Date(),
lastUpdatedBy: this.user.id,
}
})
function shouldAcceptChanges() {
it('should accept writes', function (done) {
writeContent(writeOptions, done)
})
it('should accept updates', function (done) {
updateContent(writeOptions, done)
})
it('should accept same write twice', function (done) {
writeContentTwice(writeOptions, done)
})
}
function shouldBlockChanges() {
it('should block writes', function (done) {
writeContent(writeOptions, err => {
expect(err).to.exist
expect(err.message).to.equal('non-success statusCode: 404')
done()
})
})
it('should block updates', function (done) {
updateContent(writeOptions, err => {
expect(err).to.exist
expect(err.message).to.equal('non-success statusCode: 404')
done()
})
})
}
describe('a regular doc', function () {
shouldAcceptChanges()
})
describe('after deleting the doc', function () {
beforeEach(function (done) {
this.user.deleteItemInProject(this.projectId, 'doc', this.docId, done)
})
shouldAcceptChanges()
})
describe('unknown doc', function () {
beforeEach(function () {
writeOptions.docId = new ObjectId()
})
shouldBlockChanges()
})
describe('doc in another project', function () {
beforeEach(function (done) {
this.user.createProject('foo', (error, projectId) => {
if (error) return done(error)
writeOptions.projectId = projectId
done()
})
})
shouldBlockChanges()
})
})

View File

@@ -0,0 +1,56 @@
import User from './helpers/User.mjs'
import { expect } from 'chai'
describe('EditorHttpController', function () {
beforeEach('login', function (done) {
this.user = new User()
this.user.login(done)
})
beforeEach('create project', function (done) {
this.projectName = 'wombat'
this.user.createProject(this.projectName, (error, projectId) => {
if (error) return done(error)
this.projectId = projectId
done()
})
})
beforeEach('create doc', function (done) {
this.user.createDocInProject(
this.projectId,
null,
'potato.tex',
(error, docId) => {
this.docId = docId
done(error)
}
)
})
describe('joinProject', function () {
it('should emit an empty deletedDocs array', function (done) {
this.user.joinProject(this.projectId, (error, details) => {
if (error) return done(error)
expect(details.project.deletedDocs).to.deep.equal([])
done()
})
})
describe('after deleting a doc', function () {
beforeEach(function (done) {
this.user.deleteItemInProject(this.projectId, 'doc', this.docId, done)
})
it('should include the deleted doc in the deletedDocs array', function (done) {
this.user.joinProject(this.projectId, (error, details) => {
if (error) return done(error)
expect(details.project.deletedDocs).to.deep.equal([
{ _id: this.docId, name: 'potato.tex' },
])
done()
})
})
})
})
})

View File

@@ -0,0 +1,238 @@
import Settings from '@overleaf/settings'
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs'
import { db } from '../../../app/src/infrastructure/mongodb.js'
import MetricsHelper from './helpers/metrics.mjs'
const User = UserHelper.promises
const getMetric = MetricsHelper.promises.getMetric
let MockHaveIBeenPwnedApi
before(function () {
MockHaveIBeenPwnedApi = MockHaveIBeenPwnedApiClass.instance()
})
async function getMetricReUsed() {
return await getMetric(
line => line.includes('password_re_use') && line.includes('re-used')
)
}
async function getMetricUnique() {
return await getMetric(
line => line.includes('password_re_use') && line.includes('unique')
)
}
async function getMetricFailure() {
return await getMetric(
line => line.includes('password_re_use') && line.includes('failure')
)
}
let user, previous
async function resetPassword(password) {
await user.getCsrfToken()
await user.doRequest('POST', {
url: '/user/password/reset',
form: {
email: user.email,
},
})
const token = (
await db.tokens.findOne({
'data.user_id': user._id.toString(),
})
).token
await user.doRequest('GET', {
url: `/user/password/set?passwordResetToken=${token}&email=${user.email}`,
})
const { response } = await user.doRequest('POST', {
url: '/user/password/set',
form: {
passwordResetToken: token,
password,
},
})
return response
}
describe('HaveIBeenPwnedApi', function () {
before(function () {
Settings.apis.haveIBeenPwned.enabled = true
})
after(function () {
Settings.apis.haveIBeenPwned.enabled = false
})
describe('login with weak password', function () {
beforeEach(function () {
user = new User()
user.password = 'aLeakedPassword42'
// echo -n aLeakedPassword42 | sha1sum
MockHaveIBeenPwnedApi.addPasswordByHash(
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
)
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous count', async function () {
previous = await getMetricReUsed()
})
beforeEach('login', async function () {
try {
await user.loginNoUpdate()
expect.fail('should have failed login with weak password')
} catch (err) {
expect(err).to.match(/login failed: status=400/)
expect(err.info.body).to.deep.equal({
message: {
type: 'error',
key: 'password-compromised',
text: `The password youve entered is on a public list of compromised passwords (https://haveibeenpwned.com/passwords). Please try logging in from a device youve previously used or reset your password (${Settings.siteUrl}/user/password/reset).`,
},
})
}
})
it('should track the weak password', async function () {
const after = await getMetricReUsed()
expect(after).to.equal(previous + 1)
})
})
describe('login with strong password', function () {
beforeEach(function () {
user = new User()
user.password = 'this-is-a-strong-password'
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous count', async function () {
previous = await getMetricUnique()
})
beforeEach('login', async function () {
await user.loginNoUpdate()
})
it('should track the strong password', async function () {
const after = await getMetricUnique()
expect(after).to.equal(previous + 1)
})
})
describe('when the api is producing garbage', function () {
beforeEach(function () {
user = new User()
user.password = 'trigger-garbage-output'
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous count', async function () {
previous = await getMetricFailure()
})
beforeEach('login', async function () {
await user.loginNoUpdate()
})
it('should track the failure to collect a score', async function () {
const after = await getMetricFailure()
expect(after).to.equal(previous + 1)
})
})
describe('login attempt with weak password', function () {
beforeEach(function () {
user = new User()
// echo -n aLeakedPassword42 | sha1sum
MockHaveIBeenPwnedApi.addPasswordByHash(
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
)
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous counts', async function () {
previous = {
reUsed: await getMetricReUsed(),
unique: await getMetricUnique(),
failure: await getMetricFailure(),
}
})
beforeEach('login', async function () {
try {
await user.loginWithEmailPassword(user.email, 'aLeakedPassword42')
expect.fail('expected the login request to fail')
} catch (err) {
expect(err).to.match(/login failed: status=401/)
expect(err.info.body).to.deep.equal({
message: { type: 'error', key: 'invalid-password-retry-or-reset' },
})
}
})
it('should not increment the counter', async function () {
expect(previous).to.deep.equal({
reUsed: await getMetricReUsed(),
unique: await getMetricUnique(),
failure: await getMetricFailure(),
})
})
})
describe('password reset with a weak password', function () {
beforeEach(function () {
user = new User()
// echo -n aLeakedPassword42 | sha1sum
MockHaveIBeenPwnedApi.addPasswordByHash(
'D1ABBDEEE70CBE8BBCE5D9D039C53C0CE91C0C16'
)
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous count', async function () {
previous = await getMetricReUsed()
})
beforeEach('set password', async function () {
const response = await resetPassword('aLeakedPassword42')
expect(response.statusCode).to.equal(400)
expect(response.body).to.equal(
JSON.stringify({
message: {
key: 'password-must-be-strong',
},
})
)
})
it('should track the weak password', async function () {
const after = await getMetricReUsed()
expect(after).to.equal(previous + 1)
})
})
describe('password reset with a strong password', function () {
beforeEach(function () {
user = new User()
})
beforeEach('create the user', async function () {
await user.ensureUserExists()
})
beforeEach('fetch previous count', async function () {
previous = await getMetricUnique()
})
beforeEach('set password', async function () {
const response = await resetPassword('a-strong-new-password')
expect(response.statusCode).to.equal(200)
})
it('should track the strong password', async function () {
const after = await getMetricUnique()
expect(after).to.equal(previous + 1)
})
})
})

View File

@@ -0,0 +1,96 @@
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('HealthCheckController', function () {
describe('SmokeTests', function () {
let user, projectId
const captchaDisabledBefore = Settings.recaptcha.disabled.login
beforeEach(async function () {
user = new User()
await user.login()
projectId = await user.createProject('SmokeTest')
// HACK: Inject the details into the app
Settings.smokeTest.userId = user.id
Settings.smokeTest.user = user.email
Settings.smokeTest.password = user.password
Settings.smokeTest.projectId = projectId
Settings.recaptcha.disabled.login = true
})
afterEach(function () {
Settings.recaptcha.disabled.login = captchaDisabledBefore
})
async function performSmokeTestRequest() {
const start = Date.now()
const { response, body } = await user.doRequest('GET', {
url: '/health_check/full',
json: true,
})
const end = Date.now()
expect(body).to.exist
expect(body.stats).to.exist
expect(Date.parse(body.stats.start)).to.be.within(start, start + 1000)
expect(Date.parse(body.stats.end)).to.be.within(end - 1000, end)
expect(body.stats.duration).to.be.within(0, 10000)
expect(body.stats.steps).to.be.instanceof(Array)
return { response, body }
}
describe('happy path', function () {
it('should respond with a 200 and stats', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.not.exist
expect(response.statusCode).to.equal(200)
})
})
describe('when the request is aborted', function () {
it('should not crash', async function () {
try {
await user.doRequest('GET', {
timeout: 1,
url: '/health_check/full',
json: true,
})
} catch (err) {
expect(err.code).to.be.oneOf(['ETIMEDOUT', 'ESOCKETTIMEDOUT'])
return
}
expect.fail('expected request to fail with timeout error')
})
})
describe('when the project does not exist', function () {
beforeEach(function () {
Settings.smokeTest.projectId = '404'
})
it('should respond with a 500 ', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.equal('run.101_loadEditor failed')
expect(response.statusCode).to.equal(500)
})
})
describe('when the password mismatches', function () {
beforeEach(function () {
Settings.smokeTest.password = 'foo-bar'
})
it('should respond with a 500 with mismatching password', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.equal('run.002_login failed')
expect(response.statusCode).to.equal(500)
})
})
})
})

View File

@@ -0,0 +1,279 @@
import fs from 'node:fs'
import Path from 'node:path'
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import MockV1HistoryApiClass from './mocks/MockV1HistoryApi.mjs'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs'
import { fileURLToPath } from 'node:url'
import sinon from 'sinon'
import logger from '@overleaf/logger'
import Metrics from './helpers/metrics.mjs'
import Features from '../../../app/src/infrastructure/Features.js'
const User = UserHelper.promises
let MockV1HistoryApi, MockFilestoreApi
before(function () {
MockV1HistoryApi = MockV1HistoryApiClass.instance()
MockFilestoreApi = MockFilestoreApiClass.instance()
})
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const fileContent = fs.readFileSync(
Path.join(__dirname, '../files/2pixel.png'),
'utf-8'
)
describe('HistoryTests', function () {
let user, projectId, fileId, fileHash, fileURL, blobURL, blobURLWithFallback
let historySource, filestoreSource
async function getSourceMetric(source) {
return await Metrics.promises.getMetric(
line => line.includes('request_blob') && line.includes(source)
)
}
beforeEach('create project', async function () {
user = new User()
await user.login()
projectId = await user.createProject('project1')
const project = await ProjectGetter.promises.getProject(projectId)
;({ entity_id: fileId, hash: fileHash } =
await user.uploadFileInProjectFull(
projectId,
project.rootFolder[0]._id.toString(),
'2pixel.png',
'2pixel.png',
'image/png'
))
fileURL = `/project/${projectId}/file/${fileId}`
blobURL = `/project/${projectId}/blob/${fileHash}`
blobURLWithFallback = `${blobURL}?fallback=${fileId}`
historySource = await getSourceMetric('history-v1')
filestoreSource = await getSourceMetric('filestore')
})
async function expectHistoryV1Hit() {
expect(await getSourceMetric('history-v1')).to.equal(historySource + 1)
expect(await getSourceMetric('filestore')).to.equal(filestoreSource)
}
async function expectFilestoreHit() {
expect(await getSourceMetric('history-v1')).to.equal(historySource)
expect(await getSourceMetric('filestore')).to.equal(filestoreSource + 1)
}
async function expectNoIncrement() {
expect(await getSourceMetric('history-v1')).to.equal(historySource)
expect(await getSourceMetric('filestore')).to.equal(filestoreSource)
}
describe('/project/:projectId/download/zip', function () {
let spy, downloadZIPURL
beforeEach(async function () {
spy = sinon.spy(logger, 'error')
downloadZIPURL = `/project/${projectId}/download/zip`
})
afterEach(function () {
spy.restore()
})
if (Features.hasFeature('project-history-blobs')) {
it('should work from history-v1', async function () {
const { response, body } = await user.doRequest('GET', downloadZIPURL)
expect(response.statusCode).to.equal(200)
expect(body).to.include('2pixel.png')
await expectHistoryV1Hit()
})
if (Features.hasFeature('filestore')) {
it('should work from filestore', async function () {
MockV1HistoryApi.reset()
const { response, body } = await user.doRequest('GET', downloadZIPURL)
expect(response.statusCode).to.equal(200)
expect(body).to.include('2pixel.png')
await expectFilestoreHit()
})
}
it('should not include when missing in both places', async function () {
MockFilestoreApi.reset()
MockV1HistoryApi.reset()
const { response, body } = await user.doRequest('GET', downloadZIPURL)
expect(response.statusCode).to.equal(200)
expect(
spy.args.find(([, msg]) => msg === 'error adding files to zip stream')
).to.exist
expect(body).to.not.include('2pixel.png')
await expectNoIncrement()
})
}
})
describe('/project/:projectId/blob/:hash', function () {
describe('HEAD', function () {
if (Features.hasFeature('project-history-blobs')) {
it('should fetch the file size from history-v1', async function () {
const { response } = await user.doRequest('HEAD', blobURL)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('history-v1')
expect(response.headers['content-length']).to.equal('3694')
await expectHistoryV1Hit()
})
}
it('should return 404 without fallback', async function () {
MockV1HistoryApi.reset()
const { response } = await user.doRequest('HEAD', blobURL)
expect(response.statusCode).to.equal(404)
await expectNoIncrement()
})
if (Features.hasFeature('filestore')) {
it('should fetch the file size from filestore when missing in history-v1', async function () {
MockV1HistoryApi.reset()
const { response } = await user.doRequest('HEAD', blobURLWithFallback)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('filestore')
expect(response.headers['content-length']).to.equal('3694')
await expectFilestoreHit()
})
}
it('should return 404 with both files missing', async function () {
MockFilestoreApi.reset()
MockV1HistoryApi.reset()
const { response } = await user.doRequest('HEAD', blobURLWithFallback)
expect(response.statusCode).to.equal(404)
await expectNoIncrement()
})
})
describe('GET', function () {
if (Features.hasFeature('project-history-blobs')) {
it('should fetch the file from history-v1', async function () {
const { response, body } = await user.doRequest('GET', blobURL)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('history-v1')
expect(body).to.equal(fileContent)
await expectHistoryV1Hit()
})
it('should set cache headers', async function () {
const { response } = await user.doRequest('GET', blobURL)
expect(response.headers['cache-control']).to.equal(
'private, max-age=86400, stale-while-revalidate=31536000'
)
expect(response.headers.etag).to.equal(fileHash)
})
it('should return a 304 when revalidating', async function () {
const { response, body } = await user.doRequest('GET', {
url: blobURL,
headers: { 'If-None-Match': fileHash },
})
expect(response.statusCode).to.equal(304)
expect(response.headers.etag).to.equal(fileHash)
expect(body).to.equal('')
})
}
it('should return 404 without fallback', async function () {
MockV1HistoryApi.reset()
const { response } = await user.doRequest('GET', blobURL)
expect(response.statusCode).to.equal(404)
await expectNoIncrement()
})
it('should not set cache headers on 404', async function () {
MockV1HistoryApi.reset()
const { response } = await user.doRequest('GET', blobURL)
expect(response.statusCode).to.equal(404)
expect(response.headers).not.to.have.property('cache-control')
expect(response.headers).not.to.have.property('etag')
})
if (Features.hasFeature('filestore')) {
it('should fetch the file size from filestore when missing in history-v1', async function () {
MockV1HistoryApi.reset()
const { response, body } = await user.doRequest(
'GET',
blobURLWithFallback
)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('filestore')
expect(body).to.equal(fileContent)
await expectFilestoreHit()
})
}
it('should return 404 with both files missing', async function () {
MockFilestoreApi.reset()
MockV1HistoryApi.reset()
const { response } = await user.doRequest('GET', blobURLWithFallback)
expect(response.statusCode).to.equal(404)
await expectNoIncrement()
})
})
})
// Legacy endpoint that is powered by history-v1 in SaaS
describe('/project/:projectId/file/:fileId', function () {
describe('HEAD', function () {
if (Features.hasFeature('project-history-blobs')) {
it('should fetch the file size from history-v1', async function () {
const { response } = await user.doRequest('HEAD', fileURL)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('history-v1')
expect(response.headers['content-length']).to.equal('3694')
await expectHistoryV1Hit()
})
}
if (Features.hasFeature('filestore')) {
it('should fetch the file size from filestore when missing in history-v1', async function () {
MockV1HistoryApi.reset()
const { response } = await user.doRequest('HEAD', blobURLWithFallback)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('filestore')
expect(response.headers['content-length']).to.equal('3694')
})
}
it('should return 404 with both files missing', async function () {
MockFilestoreApi.reset()
MockV1HistoryApi.reset()
const { response } = await user.doRequest('HEAD', blobURLWithFallback)
expect(response.statusCode).to.equal(404)
})
})
describe('GET', function () {
if (Features.hasFeature('project-history-blobs')) {
it('should fetch the file from history-v1', async function () {
const { response, body } = await user.doRequest('GET', fileURL)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('history-v1')
expect(body).to.equal(fileContent)
await expectHistoryV1Hit()
})
}
it('should set cache headers', async function () {
const { response } = await user.doRequest('GET', fileURL)
expect(response.headers['cache-control']).to.equal(
'private, max-age=3600'
)
})
it('should not set cache headers on 404', async function () {
MockV1HistoryApi.reset()
MockFilestoreApi.reset()
// The legacy filestore downloads are not properly handling 404s, so delete the file from the file-tree to trigger the 404. All the filestore code will be removed soon.
await user.doRequest('DELETE', fileURL)
const { response } = await user.doRequest('GET', fileURL)
expect(response.statusCode).to.equal(404)
expect(response.headers).not.to.have.property('cache-control')
expect(response.headers).not.to.have.property('etag')
})
if (Features.hasFeature('filestore')) {
it('should fetch the file size from filestore when missing in history-v1', async function () {
MockV1HistoryApi.reset()
const { response, body } = await user.doRequest('GET', fileURL)
expect(response.statusCode).to.equal(200)
expect(response.headers['x-served-by']).to.include('filestore')
expect(body).to.equal(fileContent)
})
}
it('should return 404 with both files missing', async function () {
MockFilestoreApi.reset()
MockV1HistoryApi.reset()
const { response } = await user.doRequest('GET', fileURL)
expect(response.statusCode).to.equal(404)
})
})
})
})

View File

@@ -0,0 +1,29 @@
import { expect } from 'chai'
import fetch from 'node-fetch'
import Settings from '@overleaf/settings'
const BASE_URL = `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
describe('HttpPermissionsPolicy', function () {
it('should have permissions-policy header on user-facing pages', async function () {
const response = await fetch(BASE_URL)
expect(response.headers.get('permissions-policy')).to.equal(
'accelerometer=(), attribution-reporting=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), otp-credentials=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), storage-access=(), usb=(), window-management=(), xr-spatial-tracking=(), autoplay=(self "https://videos.ctfassets.net"), fullscreen=(self)'
)
})
it('should not have permissions-policy header on requests for non-rendered content', async function () {
const response = await fetch(`${BASE_URL}/dev/csrf`)
expect(response.headers.get('permissions-policy')).to.be.null
})
describe('when permissions policy is disabled', function () {
it('it adds no additional headers', async function () {
Settings.useHttpPermissionsPolicy = false
const response = await fetch(BASE_URL)
expect(response.headers.get('permissions-policy')).to.be.null
})
})
})

View File

@@ -0,0 +1,44 @@
import './helpers/InitApp.mjs'
import Features from '../../../app/src/infrastructure/Features.js'
import MockAnalyticsApi from './mocks/MockAnalyticsApi.mjs'
import MockChatApi from './mocks/MockChatApi.mjs'
import MockClsiApi from './mocks/MockClsiApi.mjs'
import MockDocstoreApi from './mocks/MockDocstoreApi.mjs'
import MockDocUpdaterApi from './mocks/MockDocUpdaterApi.mjs'
import MockFilestoreApi from './mocks/MockFilestoreApi.mjs'
import MockGitBridgeApi from './mocks/MockGitBridgeApi.mjs'
import MockNotificationsApi from './mocks/MockNotificationsApi.mjs'
import MockProjectHistoryApi from './mocks/MockProjectHistoryApi.mjs'
import MockSpellingApi from './mocks/MockSpellingApi.mjs'
import MockV1Api from './mocks/MockV1Api.mjs'
import MockV1HistoryApi from './mocks/MockV1HistoryApi.mjs'
import MockHaveIBeenPwnedApi from './mocks/MockHaveIBeenPwnedApi.mjs'
import MockThirdPartyDataStoreApi from './mocks/MockThirdPartyDataStoreApi.mjs'
import MockHistoryBackupDeletionApi from './mocks/MockHistoryBackupDeletionApi.mjs'
const mockOpts = {
debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS),
}
MockChatApi.initialize(23010, mockOpts)
MockClsiApi.initialize(23013, mockOpts)
MockDocstoreApi.initialize(23016, mockOpts)
MockDocUpdaterApi.initialize(23003, mockOpts)
MockFilestoreApi.initialize(23009, mockOpts)
MockNotificationsApi.initialize(23042, mockOpts)
MockSpellingApi.initialize(23005, mockOpts)
MockHaveIBeenPwnedApi.initialize(1337, mockOpts)
MockProjectHistoryApi.initialize(23054, mockOpts)
MockV1HistoryApi.initialize(23100, mockOpts)
MockHistoryBackupDeletionApi.initialize(23101, mockOpts)
if (Features.hasFeature('saas')) {
MockAnalyticsApi.initialize(23050, mockOpts)
MockV1Api.initialize(25000, mockOpts)
MockThirdPartyDataStoreApi.initialize(23002, mockOpts)
}
if (Features.hasFeature('git-bridge')) {
MockGitBridgeApi.initialize(28000, mockOpts)
}

View File

@@ -0,0 +1,54 @@
import { expect } from 'chai'
import cheerio from 'cheerio'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('Spelling', function () {
let user, projectId
async function learnWord(word) {
const { response } = await user.doRequest('POST', {
url: '/spelling/learn',
json: { word },
})
return response
}
async function getDict() {
const { body, response } = await user.doRequest(
'GET',
`/project/${projectId}`
)
expect(response.statusCode).to.equal(200)
const dom = cheerio.load(body)
const metaEl = dom('meta[name="ol-learnedWords"]')[0]
return JSON.parse(metaEl.attribs.content)
}
describe('learning words', function () {
beforeEach(async function () {
user = new User()
await user.login()
projectId = await user.createProject('foo')
})
it('should return status 400 when posting an empty word', async function () {
const response = await learnWord('')
expect(response.statusCode).to.equal(400)
})
it('should return status 204 when posting a word successfully', async function () {
const response = await learnWord('abcd')
expect(response.statusCode).to.equal(204)
})
it('should not learn the same word twice', async function () {
await learnWord('foobar')
const learnResponse = await learnWord('foobar')
expect(learnResponse.statusCode).to.equal(204)
const dict = await getDict()
expect(dict.length).to.equals(1)
})
})
})

View File

@@ -0,0 +1,527 @@
import { expect } from 'chai'
import _ from 'lodash'
import timekeeper from 'timekeeper'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
import express from 'express'
import { plainTextResponse } from '../../../app/src/infrastructure/Response.js'
const User = UserHelper.promises
const LinkedUrlProxy = express()
LinkedUrlProxy.get('/', (req, res, next) => {
if (req.query.url === 'http://example.com/foo') {
return plainTextResponse(res, 'foo foo foo')
} else if (req.query.url === 'http://example.com/bar') {
return plainTextResponse(res, 'bar bar bar')
} else if (req.query.url === 'http://example.com/large') {
return plainTextResponse(res, 'x'.repeat(Settings.maxUploadSize + 1))
} else {
return res.sendStatus(404)
}
})
describe('LinkedFiles', function () {
before(function () {
timekeeper.freeze(new Date())
})
after(function () {
timekeeper.reset()
})
let projectOne, projectOneId, projectOneRootFolderId
let projectTwo, projectTwoId, projectTwoRootFolderId
const sourceDocName = 'test.txt'
let sourceDocId
let owner
let server
before(function (done) {
server = LinkedUrlProxy.listen(6543, done)
})
after(function (done) {
server.close(done)
})
beforeEach(async function () {
owner = new User()
await owner.login()
})
describe('creating a project linked file', function () {
beforeEach(async function () {
projectOneId = await owner.createProject('plf-test-one', {
template: 'blank',
})
projectOne = await owner.getProject(projectOneId)
projectOneRootFolderId = projectOne.rootFolder[0]._id.toString()
projectTwoId = await owner.createProject('plf-test-two', {
template: 'blank',
})
projectTwo = await owner.getProject(projectTwoId)
projectTwoRootFolderId = projectTwo.rootFolder[0]._id.toString()
sourceDocId = await owner.createDocInProject(
projectTwoId,
projectTwoRootFolderId,
sourceDocName
)
await owner.createDocInProject(
projectTwoId,
projectTwoRootFolderId,
'some-harmless-doc.txt'
)
})
it('should produce a list of the users projects and their entities', async function () {
let { body } = await owner.doRequest('get', {
url: '/user/projects',
json: true,
})
expect(body).to.deep.equal({
projects: [
{
_id: projectOneId,
name: 'plf-test-one',
accessLevel: 'owner',
},
{
_id: projectTwoId,
name: 'plf-test-two',
accessLevel: 'owner',
},
],
})
;({ body } = await owner.doRequest('get', {
url: `/project/${projectTwoId}/entities`,
json: true,
}))
expect(body).to.deep.equal({
project_id: projectTwoId,
entities: [
{ path: '/main.tex', type: 'doc' },
{ path: '/some-harmless-doc.txt', type: 'doc' },
{ path: '/test.txt', type: 'doc' },
],
})
})
it('should import a file and refresh it if there is no v1 id', async function () {
// import the file from the source project
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
name: 'test-link.txt',
parent_folder_id: projectOneRootFolderId,
provider: 'project_file',
data: {
source_project_id: projectTwoId,
source_entity_path: `/${sourceDocName}`,
},
},
})
expect(response.statusCode).to.equal(200)
const existingFileId = body.new_file_id
expect(existingFileId).to.exist
let updatedProjectOne = await owner.getProject(projectOneId)
let firstFile = updatedProjectOne.rootFolder[0].fileRefs[0]
expect(firstFile._id.toString()).to.equal(existingFileId.toString())
expect(firstFile.linkedFileData).to.deep.equal({
provider: 'project_file',
source_project_id: projectTwoId,
source_entity_path: `/${sourceDocName}`,
importedAt: new Date().toISOString(),
})
expect(firstFile.name).to.equal('test-link.txt')
// refresh the file
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file/${existingFileId}/refresh`,
json: true,
}))
expect(response.statusCode).to.equal(200)
const newFileId = body.new_file_id
expect(newFileId).to.exist
expect(newFileId).to.not.equal(existingFileId)
updatedProjectOne = await owner.getProject(projectOneId)
firstFile = updatedProjectOne.rootFolder[0].fileRefs[0]
expect(firstFile._id.toString()).to.equal(newFileId.toString())
expect(firstFile.name).to.equal('test-link.txt')
// should not work if there is a v1 id
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
name: 'test-link-should-not-work.txt',
parent_folder_id: projectOneRootFolderId,
provider: 'project_file',
data: {
v1_source_doc_id: 1234,
source_entity_path: `/${sourceDocName}`,
},
},
}))
expect(response.statusCode).to.equal(403)
expect(body).to.equal(
'The project that contains this file is not shared with you'
)
})
it('should generate a proper error message when the source file has been deleted', async function () {
// import the file from the source project
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
name: 'test-link.txt',
parent_folder_id: projectOneRootFolderId,
provider: 'project_file',
data: {
source_project_id: projectTwoId,
source_entity_path: `/${sourceDocName}`,
},
},
})
expect(response.statusCode).to.equal(200)
const existingFileId = body.new_file_id
expect(existingFileId).to.exist
// rename the source file
await owner.renameItemInProject(
projectTwoId,
'doc',
sourceDocId,
'renamed-doc.txt'
)
// refresh the file
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file/${existingFileId}/refresh`,
json: true,
}))
expect(response.statusCode).to.equal(404)
expect(body).to.equal('Source file not found')
})
})
describe('with a linked project_file from a v1 project that has not been imported', function () {
beforeEach(async function () {
projectOneId = await owner.createProject('plf-v1-test-one', {
template: 'blank',
})
projectOne = await owner.getProject(projectOneId)
projectOneRootFolderId = projectOne.rootFolder[0]._id.toString()
projectOne.rootFolder[0].fileRefs.push({
linkedFileData: {
provider: 'project_file',
v1_source_doc_id: 9999999, // We won't find this id in the database
source_entity_path: 'example.jpeg',
},
_id: 'abcd',
rev: 0,
created: new Date(),
name: 'example.jpeg',
})
await owner.saveProject(projectOne)
})
it('should refuse to refresh', async function () {
const { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file/abcd/refresh`,
json: true,
})
expect(response.statusCode).to.equal(409)
expect(body).to.equal(
'Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file'
)
})
})
describe('creating a URL based linked file', function () {
beforeEach(async function () {
projectOneId = await owner.createProject('url-linked-files-project', {
template: 'blank',
})
projectOne = await owner.getProject(projectOneId)
projectOneRootFolderId = projectOne.rootFolder[0]._id.toString()
})
it('should download, create and replace a file', async function () {
// downloading the initial file
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'http://example.com/foo',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-1',
},
})
expect(response.statusCode).to.equal(200)
let updatedProject = await owner.getProject(projectOneId)
let file = updatedProject.rootFolder[0].fileRefs[0]
expect(file.linkedFileData).to.deep.equal({
provider: 'url',
url: 'http://example.com/foo',
importedAt: new Date().toISOString(),
})
;({ response, body } = await owner.doRequest(
'get',
`/project/${projectOneId}/file/${file._id}`
))
expect(response.statusCode).to.equal(200)
expect(body).to.equal('foo foo foo')
// replacing the file
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'http://example.com/foo',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-2',
},
}))
expect(response.statusCode).to.equal(200)
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'http://example.com/bar',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-2',
},
}))
expect(response.statusCode).to.equal(200)
updatedProject = await owner.getProject(projectOneId)
file = updatedProject.rootFolder[0].fileRefs[1]
expect(file.linkedFileData).to.deep.equal({
provider: 'url',
url: 'http://example.com/bar',
importedAt: new Date().toISOString(),
})
;({ response, body } = await owner.doRequest(
'get',
`/project/${projectOneId}/file/${file._id}`
))
expect(response.statusCode).to.equal(200)
expect(body).to.equal('bar bar bar')
})
it('should return an error if the file exceeds the maximum size', async function () {
// download does not succeed
const { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'http://example.com/large',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-large-file-1',
},
})
expect(response.statusCode).to.equal(422)
expect(body).to.equal('File too large')
})
it("should return an error if the file can't be downloaded", async function () {
// download does not succeed
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'http://example.com/does-not-exist',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-3',
},
})
expect(response.statusCode).to.equal(422) // unprocessable
expect(body).to.equal(
'Your URL could not be reached (404 status code). Please check it and try again.'
)
// url is invalid
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: '!^$%',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-4',
},
}))
expect(response.statusCode).to.equal(422) // unprocessable
expect(body).to.equal(
'Your URL is not valid. Please check it and try again.'
)
// URL is non-http
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'ftp://127.0.0.1',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-5',
},
}))
expect(response.statusCode).to.equal(422) // unprocessable
expect(body).to.equal(
'Your URL is not valid. Please check it and try again.'
)
})
it('should accept a URL withuot a leading http://, and add it', async function () {
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
provider: 'url',
data: {
url: 'example.com/foo',
},
parent_folder_id: projectOneRootFolderId,
name: 'url-test-file-6',
},
})
expect(response.statusCode).to.equal(200)
const updatedProject = await owner.getProject(projectOneId)
const file = _.find(
updatedProject.rootFolder[0].fileRefs,
file => file.name === 'url-test-file-6'
)
expect(file.linkedFileData).to.deep.equal({
provider: 'url',
url: 'http://example.com/foo',
importedAt: new Date().toISOString(),
})
;({ response, body } = await owner.doRequest(
'get',
`/project/${projectOneId}/file/${file._id}`
))
expect(response.statusCode).to.equal(200)
expect(body).to.equal('foo foo foo')
})
})
// TODO: Add test for asking for host that return ENOTFOUND
// (This will probably end up handled by the proxy)
describe('creating a linked output file', function () {
beforeEach(async function () {
projectOneId = await owner.createProject('output-test-one', {
template: 'blank',
})
projectOne = await owner.getProject(projectOneId)
projectOneRootFolderId = projectOne.rootFolder[0]._id.toString()
projectTwoId = await owner.createProject('output-test-two', {
template: 'blank',
})
projectTwo = await owner.getProject(projectTwoId)
projectTwoRootFolderId = projectTwo.rootFolder[0]._id.toString()
})
it('should import the project.pdf file from the source project and refresh it', async function () {
// import the file
let { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file`,
json: {
name: 'test.pdf',
parent_folder_id: projectOneRootFolderId,
provider: 'project_output_file',
data: {
source_project_id: projectTwoId,
source_output_file_path: 'project.pdf',
build_id: '1234-abcd',
},
},
})
expect(response.statusCode).to.equal(200)
const existingFileId = body.new_file_id
expect(existingFileId).to.exist
const updatedProject = await owner.getProject(projectOneId)
const firstFile = updatedProject.rootFolder[0].fileRefs[0]
expect(firstFile._id.toString()).to.equal(existingFileId.toString())
expect(firstFile.linkedFileData).to.deep.equal({
provider: 'project_output_file',
source_project_id: projectTwoId,
source_output_file_path: 'project.pdf',
build_id: '1234-abcd',
importedAt: new Date().toISOString(),
})
expect(firstFile.name).to.equal('test.pdf')
// refresh the file
;({ response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file/${existingFileId}/refresh`,
json: true,
}))
expect(response.statusCode).to.equal(200)
const refreshedFileId = body.new_file_id
expect(refreshedFileId).to.exist
expect(refreshedFileId).to.not.equal(existingFileId)
const refreshedProject = await owner.getProject(projectOneId)
const refreshedFile = refreshedProject.rootFolder[0].fileRefs[0]
expect(refreshedFile._id.toString()).to.equal(refreshedFileId.toString())
expect(refreshedFile.name).to.equal('test.pdf')
})
})
describe('with a linked project_output_file from a v1 project that has not been imported', function () {
beforeEach(async function () {
projectOneId = await owner.createProject('output-v1-test-one', {
template: 'blank',
})
projectOne = await owner.getProject(projectOneId)
projectOneRootFolderId = projectOne.rootFolder[0]._id.toString()
projectOne.rootFolder[0].fileRefs.push({
linkedFileData: {
provider: 'project_output_file',
v1_source_doc_id: 9999999, // We won't find this id in the database
source_output_file_path: 'project.pdf',
},
_id: 'abcdef',
rev: 0,
created: new Date(),
name: 'whatever.pdf',
})
await owner.saveProject(projectOne)
})
it('should refuse to refresh', async function () {
const { response, body } = await owner.doRequest('post', {
url: `/project/${projectOneId}/linked_file/abcdef/refresh`,
json: true,
})
expect(response.statusCode).to.equal(409)
expect(body).to.equal(
'Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file'
)
})
})
})

View File

@@ -0,0 +1,733 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
const lastUpdated = new Date(42)
const lastUpdatedBy = new ObjectId()
const lastUpdatedChanged = new Date(1337)
async function runScriptFind() {
try {
const result = await promisify(exec)(
['node', 'scripts/find_malformed_filetrees.mjs'].join(' ')
)
return result.stdout.split('\n').filter(filterOutput)
} catch (error) {
logger.error({ error }, 'script failed')
throw error
}
}
async function runScriptFix(instructions) {
const adhocFile = instructions.map(entry => JSON.stringify(entry)).join('\n')
try {
return await promisify(exec)(
[
'node',
'scripts/fix_malformed_filetree.mjs',
`--logs=<(echo '${adhocFile}')`,
].join(' '),
{ shell: '/bin/bash' }
)
} catch (error) {
logger.error({ error }, 'fix script failed unexpectedly')
throw error
}
}
const findProjects = () =>
db.projects
.find(
{},
{
projection: {
rootFolder: 1,
_id: 1,
version: 1,
lastUpdated: 1,
lastUpdatedBy: 1,
},
}
)
.toArray()
const projectId = new ObjectId()
const rootFolderId = new ObjectId()
const idDic = {}
const id = key => {
if (!idDic[key]) {
idDic[key] = new ObjectId()
}
return idDic[key]
}
const strId = key => {
return idDic[key].toString()
}
const wellFormedFolder = name => ({
_id: id(name),
name,
folders: [],
docs: [],
fileRefs: [],
})
const wellFormedDoc = name => ({ _id: id(name), name })
const wellFormedFileRef = name => ({ _id: id(name), name, hash: 'h' })
const wellFormedProject = {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [wellFormedFolder('f00'), wellFormedFolder('f01')],
docs: [wellFormedDoc('d00'), wellFormedDoc('d01')],
fileRefs: [wellFormedFileRef('fr00'), wellFormedFileRef('fr01')],
},
],
lastUpdated,
lastUpdatedBy,
}
const testCases = [
...[{}, { rootFolder: undefined }, { rootFolder: '1234' }].map(
(project, idx) => ({
name: `bad rootFolder ${idx + 1}`,
project: { _id: projectId, ...project, lastUpdated, lastUpdatedBy },
expectFind: [
{
_id: null,
projectId: projectId.toString(),
msg: 'bad file-tree path',
reason: 'bad rootFolder',
path: 'rootFolder',
},
],
// FIXME: This is a bug in the script.
expectFixError: 'Unexpected mongo path: rootFolder',
})
),
{
name: `missing rootFolder`,
project: { _id: projectId, rootFolder: [], lastUpdated, lastUpdatedBy },
expectFind: [
{
_id: null,
projectId: projectId.toString(),
msg: 'bad file-tree path',
reason: 'missing rootFolder',
path: 'rootFolder.0',
},
],
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":1,"success":1,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject.rootFolder[0]._id).to.be.an.instanceOf(ObjectId)
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: updatedProject.rootFolder[0]._id,
name: 'rootFolder',
folders: [],
fileRefs: [],
docs: [],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'empty folder',
project: {
_id: projectId,
rootFolder: [{ _id: '1234' }],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder id', path: 'rootFolder.0._id' },
{ reason: 'bad folder name', path: 'rootFolder.0.name' },
{ reason: 'missing .folders', path: 'rootFolder.0.folders' },
{ reason: 'missing .docs', path: 'rootFolder.0.docs' },
{ reason: 'missing .fileRefs', path: 'rootFolder.0.fileRefs' },
].map(entry => ({
...entry,
_id: '1234',
msg: 'bad file-tree path',
projectId: String(projectId),
})),
// FIXME: This is a bug in the script.
expectFixError: 'Unexpected mongo path: rootFolder.0._id',
},
{
name: 'missing fields',
project: {
_id: projectId,
rootFolder: [{ _id: rootFolderId }],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder name', path: 'rootFolder.0.name' },
{ reason: 'missing .folders', path: 'rootFolder.0.folders' },
{ reason: 'missing .docs', path: 'rootFolder.0.docs' },
{ reason: 'missing .fileRefs', path: 'rootFolder.0.fileRefs' },
].map(entry => ({
...entry,
_id: rootFolderId.toString(),
msg: 'bad file-tree path',
projectId: String(projectId),
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":4,"success":4,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
docs: [],
fileRefs: [],
folders: [],
name: 'rootFolder',
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'bad folder, bad doc, bad fileRef',
project: {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [null],
docs: [null],
fileRefs: [null, null],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{
path: 'rootFolder.0.folders.0',
reason: 'bad folder',
},
{
path: 'rootFolder.0.docs.0',
reason: 'bad doc',
},
{
path: 'rootFolder.0.fileRefs.0',
reason: 'bad file',
},
{
path: 'rootFolder.0.fileRefs.1',
reason: 'bad file',
},
].map(entry => ({
...entry,
_id: rootFolderId.toString(),
projectId: projectId.toString(),
msg: 'bad file-tree path',
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":4,"success":1,"alreadyProcessed":3,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
docs: [],
fileRefs: [],
folders: [],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'bad [folder|doc|fileRef] id',
project: {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
{ _id: 123, name: 'file-a', folders: [], docs: [], fileRefs: [] },
{ name: 'file-b', folders: [], docs: [], fileRefs: [] },
],
docs: [{ _id: '456', name: 'doc-a' }, { name: 'doc-b' }],
fileRefs: [{ _id: null, name: 'ref-a' }, { name: 'ref-b' }],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{ reason: 'bad folder id', path: 'rootFolder.0.folders.0._id', _id: 123 },
{ reason: 'bad folder id', path: 'rootFolder.0.folders.1._id' },
{ reason: 'bad doc id', path: 'rootFolder.0.docs.0._id', _id: '456' },
{ reason: 'bad doc id', path: 'rootFolder.0.docs.1._id' },
{ reason: 'bad file id', path: 'rootFolder.0.fileRefs.0._id', _id: null },
{ reason: 'bad file id', path: 'rootFolder.0.fileRefs.1._id' },
].map(entry => ({
...entry,
projectId: projectId.toString(),
msg: 'bad file-tree path',
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":6,"success":3,"alreadyProcessed":3,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
{ _id: 123, name: 'file-a', folders: [], docs: [], fileRefs: [] },
{
_id: updatedProject.rootFolder[0].folders[1]._id,
name: 'file-b',
folders: [],
docs: [],
fileRefs: [],
},
],
docs: [{ _id: '456', name: 'doc-a' }],
fileRefs: [],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'bad [folder|doc|fileRef] name',
project: {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
{ _id: id('f00'), folders: [], docs: [], fileRefs: [] },
{ _id: id('f01'), name: 8, folders: [], docs: [], fileRefs: [] },
],
docs: [{ _id: id('d00') }, { _id: id('d01'), name: null }],
fileRefs: [
{ _id: id('fr00'), hash: 'h' },
{ _id: id('fr01'), hash: 'h', name: [] },
],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{
reason: 'bad folder name',
path: 'rootFolder.0.folders.0.name',
_id: strId('f00'),
},
{
reason: 'bad folder name',
path: 'rootFolder.0.folders.1.name',
_id: strId('f01'),
},
{
reason: 'bad doc name',
path: 'rootFolder.0.docs.0.name',
_id: strId('d00'),
},
{
reason: 'bad doc name',
path: 'rootFolder.0.docs.1.name',
_id: strId('d01'),
},
{
reason: 'bad file name',
path: 'rootFolder.0.fileRefs.0.name',
_id: strId('fr00'),
},
{
reason: 'bad file name',
path: 'rootFolder.0.fileRefs.1.name',
_id: strId('fr01'),
},
].map(entry => ({
...entry,
projectId: projectId.toString(),
msg: 'bad file-tree path',
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":6,"success":6,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
{
_id: id('f00'),
name: 'untitled',
folders: [],
docs: [],
fileRefs: [],
},
{
_id: id('f01'),
name: 'untitled-1',
folders: [],
docs: [],
fileRefs: [],
},
],
docs: [
{ _id: id('d00'), name: 'untitled' },
{ _id: id('d01'), name: 'untitled-1' },
],
fileRefs: [
{ _id: id('fr00'), hash: 'h', name: 'untitled' },
{ _id: id('fr01'), hash: 'h', name: 'untitled-1' },
],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'bad file hash',
project: {
...wellFormedProject,
rootFolder: [
{
...wellFormedProject.rootFolder[0],
fileRefs: [
{ _id: id('fa'), name: 'ref-a', hash: null },
{ _id: id('fb'), name: 'ref-b', hash: {} },
],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{ path: 'rootFolder.0.fileRefs.0.hash', _id: strId('fa') },
{ path: 'rootFolder.0.fileRefs.1.hash', _id: strId('fb') },
].map(entry => ({
...entry,
projectId: projectId.toString(),
reason: 'bad file hash',
msg: 'bad file-tree path',
})),
expectFixError: new RegExp(
`Missing file hash: ${projectId.toString()}/${id('fa').toString()}`
),
},
{
name: 'well formed filetrees',
project: wellFormedProject,
expectFind: [],
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":1,"success":0,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal(wellFormedProject)
},
},
{
name: 'bug: shifted arrays in filetree',
project: {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [null, null, { ...wellFormedFolder('f02'), name: null }],
docs: [null, null, { ...wellFormedDoc('d02'), name: null }],
fileRefs: [null, null, { ...wellFormedFileRef('fr02'), name: null }],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.folders.0',
reason: 'bad folder',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.folders.1',
reason: 'bad folder',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.name',
reason: 'bad folder name',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.docs.0',
reason: 'bad doc',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.docs.1',
reason: 'bad doc',
},
{
_id: strId('d02'),
path: 'rootFolder.0.docs.2.name',
reason: 'bad doc name',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.fileRefs.0',
reason: 'bad file',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.fileRefs.1',
reason: 'bad file',
},
{
_id: strId('fr02'),
path: 'rootFolder.0.fileRefs.2.name',
reason: 'bad file name',
},
].map(entry => ({
...entry,
projectId: projectId.toString(),
msg: 'bad file-tree path',
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":9,"success":4,"alreadyProcessed":5,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [{ ...wellFormedFolder('f02'), name: 'untitled' }],
docs: [{ ...wellFormedDoc('d02'), name: 'untitled' }],
fileRefs: [{ ...wellFormedFileRef('fr02'), name: 'untitled' }],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
{
name: 'bug: shifted arrays in filetree folder',
project: {
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
null,
null,
{
...wellFormedFolder('f02'),
name: 'folder 1',
folders: [null, null, { ...wellFormedFolder('f022') }],
docs: [null, null, { ...wellFormedDoc('d022'), name: null }],
fileRefs: [
null,
null,
{ ...wellFormedFileRef('fr022'), name: null },
],
},
],
docs: [],
fileRefs: [],
},
],
lastUpdated,
lastUpdatedBy,
},
expectFind: [
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.folders.0',
reason: 'bad folder',
},
{
_id: rootFolderId.toString(),
path: 'rootFolder.0.folders.1',
reason: 'bad folder',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.folders.0',
reason: 'bad folder',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.folders.1',
reason: 'bad folder',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.docs.0',
reason: 'bad doc',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.docs.1',
reason: 'bad doc',
},
{
_id: strId('d022'),
path: 'rootFolder.0.folders.2.docs.2.name',
reason: 'bad doc name',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.fileRefs.0',
reason: 'bad file',
},
{
_id: strId('f02'),
path: 'rootFolder.0.folders.2.fileRefs.1',
reason: 'bad file',
},
{
_id: strId('fr022'),
path: 'rootFolder.0.folders.2.fileRefs.2.name',
reason: 'bad file name',
},
].map(entry => ({
...entry,
projectId: projectId.toString(),
msg: 'bad file-tree path',
})),
expectFixStdout:
'"gracefulShutdownInitiated":false,"processedLines":10,"success":4,"alreadyProcessed":6,"hash":0,"failed":0,"unmatched":0',
expectProject: updatedProject => {
expect(updatedProject).to.deep.equal({
_id: projectId,
rootFolder: [
{
_id: rootFolderId,
name: 'rootFolder',
folders: [
{
...wellFormedFolder('f02'),
name: 'folder 1',
docs: [
{
...wellFormedDoc('d022'),
name: 'untitled',
},
],
fileRefs: [
{
...wellFormedFileRef('fr022'),
// FIXME: Make the names unique across different file types
name: 'untitled',
},
],
folders: [
{
...wellFormedFolder('f022'),
name: 'f022',
folders: [],
docs: [],
fileRefs: [],
},
],
},
],
docs: [],
fileRefs: [],
},
],
lastUpdated: lastUpdatedChanged,
lastUpdatedBy: null,
})
},
},
]
describe('find_malformed_filetrees and fix_malformed_filetree scripts', function () {
testCases.forEach(
({
name,
project,
expectFind,
expectFixStdout,
expectFixError,
expectProject,
}) => {
describe(name, function () {
beforeEach(async function () {
await db.projects.insertOne(project)
})
it('finds malformed filetree', async function () {
const stdout = await runScriptFind()
expect(stdout.map(line => JSON.parse(line))).to.deep.equal(expectFind)
})
if (expectFixError) {
it('fails to fix malformed filetrees', async function () {
await expect(runScriptFix(expectFind)).to.be.rejectedWith(
expectFixError
)
})
} else {
it('fixes malformed filetrees', async function () {
const { stdout } = await runScriptFix(expectFind)
expect(expectFixStdout).to.be.a('string')
expect(stdout).to.include(expectFixStdout)
const [updatedProject] = await findProjects()
if (updatedProject.lastUpdated > lastUpdated) {
updatedProject.lastUpdated = lastUpdatedChanged
}
expectProject(updatedProject)
})
}
})
}
)
})

View File

@@ -0,0 +1,82 @@
import { expect } from 'chai'
import { User } from '../../../app/src/models/User.js'
import { Subscription } from '../../../app/src/models/Subscription.js'
describe('mongoose', function () {
describe('User', function () {
const email = 'wombat@potato.net'
it('allows the creation of a user', async function () {
await expect(User.create({ email })).to.be.fulfilled
await expect(User.findOne({ email }, { _id: 1 })).to.eventually.exist
})
it('does not allow the creation of multiple users with the same email', async function () {
await expect(User.create({ email })).to.be.fulfilled
await expect(User.create({ email })).to.be.rejected
await expect(User.countDocuments({ email })).to.eventually.equal(1)
})
it('formats assignedAt as Date', async function () {
await expect(
User.create({
email,
splitTests: {
'some-test': [
{
variantName: 'control',
versionNumber: 1,
phase: 'release',
assignedAt: '2021-09-24T11:53:18.313Z',
},
{
variantName: 'control',
versionNumber: 2,
phase: 'release',
assignedAt: new Date(),
},
],
},
})
).to.be.fulfilled
const user = await User.findOne({ email }, { splitTests: 1 })
expect(user.splitTests['some-test'][0].assignedAt).to.be.a('date')
expect(user.splitTests['some-test'][1].assignedAt).to.be.a('date')
})
})
describe('Subscription', function () {
let user
beforeEach(async function () {
user = await User.create({ email: 'wombat@potato.net' })
})
it('allows the creation of a subscription', async function () {
await expect(
Subscription.create({
admin_id: user._id,
manager_ids: [user._id],
salesforce_id: 'a0a0a00000AAA0AAAA',
})
).to.be.fulfilled
await expect(Subscription.findOne({ admin_id: user._id })).to.eventually
.exist
})
it('does not allow the creation of a subscription without a manager', async function () {
await expect(Subscription.create({ admin_id: user._id })).to.be.rejected
})
it('does not allow the creation of a subscription with an invalid salesforce_id', async function () {
await expect(
Subscription.create({
admin_id: user._id,
manager_ids: [user._id],
salesforce_id: 'a00aaaAAa0000a',
})
).to.be.rejected
})
})
})

View File

@@ -0,0 +1,197 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import mongoose from 'mongoose'
import { User as UserModel } from '../../../app/src/models/User.js'
import { db } from '../../../app/src/infrastructure/mongodb.js'
import {
normalizeQuery,
normalizeMultiQuery,
} from '../../../app/src/Features/Helpers/Mongo.js'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
const NativeObjectId = mongodb.ObjectId
const MongooseObjectId = mongoose.Types.ObjectId
describe('MongoTests', function () {
let userIdAsString, userEmail, userIds
beforeEach(async function setUpUsers() {
// the first user in the db should not match the target user
const otherUser = new User()
await otherUser.ensureUserExists()
const user = new User()
await user.ensureUserExists()
userIdAsString = user.id
userEmail = user.email
// the last user in the db should not match the target user
const yetAnotherUser = new User()
await yetAnotherUser.ensureUserExists()
userIds = [otherUser.id, user.id, yetAnotherUser.id]
})
describe('normalizeQuery', function () {
async function expectToWork(blob) {
const query = normalizeQuery(blob)
expect(query).to.exist
expect(query._id).to.be.instanceof(NativeObjectId)
expect(query._id).to.deep.equal(new NativeObjectId(userIdAsString))
const user = await db.users.findOne(query)
expect(user).to.exist
expect(user.email).to.equal(userEmail)
}
it('should work with the user id as string', async function () {
await expectToWork(userIdAsString)
})
it('should work with the user id in an object', async function () {
await expectToWork({ _id: userIdAsString })
})
it('should pass back the object with id', function () {
const inputQuery = { _id: userIdAsString, other: 1 }
const query = normalizeMultiQuery(inputQuery)
expect(inputQuery).to.equal(query)
})
describe('with an ObjectId from mongoose', function () {
let user
beforeEach(async function getUser() {
user = await UserModel.findById(userIdAsString).exec()
expect(user).to.exist
expect(user._id).to.exist
expect(user.email).to.equal(userEmail)
})
it('should have a mongoose ObjectId', function () {
expect(user._id).to.be.instanceof(MongooseObjectId)
})
it('should work with the users _id field', async function () {
await expectToWork(user._id)
})
})
describe('with an ObjectId from the native driver', function () {
let user
beforeEach(async function getUser() {
user = await db.users.findOne({
_id: new NativeObjectId(userIdAsString),
})
expect(user).to.exist
expect(user._id).to.exist
expect(user.email).to.equal(userEmail)
})
it('should have a native ObjectId', function () {
expect(user._id).to.be.instanceof(NativeObjectId)
})
it('should work with the users _id field', async function () {
await expectToWork(user._id)
})
})
})
describe('normalizeMultiQuery', function () {
let ghost
beforeEach(async function addGhost() {
// add a user which is not part of the initial three users
ghost = new User()
ghost.emails[0].email = ghost.email = 'ghost@ghost.com'
await ghost.ensureUserExists()
})
async function expectToFindTheThreeUsers(query) {
const users = await db.users.find(query).toArray()
expect(users).to.have.length(3)
expect(users.map(user => user._id.toString()).sort()).to.deep.equal(
userIds.sort()
)
}
describe('with an array as query', function () {
function expectInQueryWithNativeObjectIds(query) {
expect(query).to.exist
expect(query._id).to.exist
expect(query._id.$in).to.exist
expect(
query._id.$in.map(id => id instanceof NativeObjectId)
).to.deep.equal([true, true, true])
}
it('should transform all strings to native ObjectIds', function () {
const query = normalizeMultiQuery(userIds)
expectInQueryWithNativeObjectIds(query)
})
it('should transform all Mongoose ObjectIds to native ObjectIds', function () {
const query = normalizeMultiQuery(
userIds.map(userId => new NativeObjectId(userId))
)
expectInQueryWithNativeObjectIds(query)
})
it('should leave all native Objects as native ObjectIds', function () {
const query = normalizeMultiQuery(
userIds.map(userId => new NativeObjectId(userId))
)
expectInQueryWithNativeObjectIds(query)
})
it('should find the three users from string ids', async function () {
const query = normalizeMultiQuery(userIds)
await expectToFindTheThreeUsers(query)
})
it('should find the three users from Mongoose ObjectIds', async function () {
const query = normalizeMultiQuery(
userIds.map(userId => new NativeObjectId(userId))
)
await expectToFindTheThreeUsers(query)
})
it('should find the three users from native ObjectIds', async function () {
const query = normalizeMultiQuery(
userIds.map(userId => new NativeObjectId(userId))
)
await expectToFindTheThreeUsers(query)
})
})
describe('with an object as query', function () {
beforeEach(async function addHiddenFlag() {
// add a mongo field that does not exist on the other users
await ghost.mongoUpdate({ $set: { hidden: 1 } })
})
it('should pass through the query', function () {
const inputQuery = { complex: 1 }
const query = normalizeMultiQuery(inputQuery)
expect(inputQuery).to.equal(query)
})
describe('when searching for hidden users', function () {
it('should match the ghost only', async function () {
const query = normalizeMultiQuery({ hidden: 1 })
const users = await db.users.find(query).toArray()
expect(users).to.have.length(1)
expect(users[0]._id.toString()).to.equal(ghost.id)
})
})
describe('when searching for non hidden users', function () {
it('should find the three users', async function () {
const query = normalizeMultiQuery({ hidden: { $exists: false } })
await expectToFindTheThreeUsers(query)
})
})
})
})
})

View File

@@ -0,0 +1,502 @@
import { expect } from 'chai'
import UserHelper from './helpers/UserHelper.mjs'
import { db } from '../../../app/src/infrastructure/mongodb.js'
describe('PasswordReset', function () {
let email, response, user, userHelper, token, emailQuery
beforeEach(async function () {
userHelper = new UserHelper()
email = 'somecooluser@example.com'
emailQuery = `?email=${encodeURIComponent(email)}`
userHelper = await UserHelper.createUser({ email })
user = userHelper.user
// generate the token
await userHelper.getCsrfToken()
response = await userHelper.fetch('/user/password/reset', {
method: 'POST',
body: new URLSearchParams({ email }),
})
token = (
await db.tokens.findOne({
'data.user_id': user._id.toString(),
})
).token
})
describe('with a valid token', function () {
describe('when logged in', function () {
beforeEach(async function () {
userHelper = await UserHelper.loginUser({
email,
password: userHelper.getDefaultPassword(),
})
response = await userHelper.fetch(
`/user/password/set?passwordResetToken=${token}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url(`/user/password/set${emailQuery}`).toString()
)
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
})
it('update the password', async function () {
expect(user.hashedPassword).to.exist
expect(user.password).to.not.exist
})
it('log the change with initiatorId', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.exist
expect(auditLog[0]).to.exist
expect(typeof auditLog[0].initiatorId).to.equal('object')
expect(auditLog[0].initiatorId).to.deep.equal(user._id)
expect(auditLog[0].operation).to.equal('reset-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('when logged in as another user', function () {
let otherUser, otherUserEmail
beforeEach(async function () {
otherUserEmail = userHelper.getDefaultEmail()
userHelper = await UserHelper.createUser({ email: otherUserEmail })
otherUser = userHelper.user
userHelper = await UserHelper.loginUser({
email: otherUserEmail,
password: userHelper.getDefaultPassword(),
})
response = await userHelper.fetch(
`/user/password/set?passwordResetToken=${token}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url(`/user/password/set${emailQuery}`).toString()
)
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
})
it('update the password', async function () {
expect(user.hashedPassword).to.exist
expect(user.password).to.not.exist
})
it('log the change with the logged in user as the initiatorId', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.exist
expect(auditLog[0]).to.exist
expect(typeof auditLog[0].initiatorId).to.equal('object')
expect(auditLog[0].initiatorId).to.deep.equal(otherUser._id)
expect(auditLog[0].operation).to.equal('reset-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('when not logged in', function () {
beforeEach(async function () {
response = await userHelper.fetch(
`/user/password/set?passwordResetToken=${token}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url(`/user/password/set${emailQuery}`).toString()
)
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
})
it('updates the password', function () {
expect(user.hashedPassword).to.exist
expect(user.password).to.not.exist
})
it('log the change', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.exist
expect(auditLog[0]).to.exist
expect(auditLog[0].initiatorId).to.equal(null)
expect(auditLog[0].operation).to.equal('reset-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('password checks', function () {
beforeEach(async function () {
response = await userHelper.fetch(
`/user/password/set?passwordResetToken=${token}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url(`/user/password/set${emailQuery}`).toString()
)
})
it('without a password should return 400 and not log the change', async function () {
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
}),
})
expect(response.status).to.equal(400)
userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
it('without a valid password should return 400 and not log the change', async function () {
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'short',
}),
})
expect(response.status).to.equal(400)
userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
it('should flag email in password', async function () {
const localPart = email.split('@').shift()
// send bad password
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
passwordResetToken: token,
password: localPart,
email,
}),
})
expect(response.status).to.equal(400)
const body = await response.json()
expect(body).to.deep.equal({
message: {
type: 'error',
key: 'password-contains-email',
text: 'Password cannot contain parts of email address',
},
})
})
it('should flag password too similar to email', async function () {
const localPart = email.split('@').shift()
const localPartReversed = localPart.split('').reverse().join('')
// send bad password
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
passwordResetToken: token,
password: `${localPartReversed}123`,
email,
}),
})
expect(response.status).to.equal(400)
const body = await response.json()
expect(body).to.deep.equal({
message: {
type: 'error',
key: 'password-too-similar',
text: 'Password is too similar to parts of email address',
},
})
})
it('should be able to retry after providing an invalid password', async function () {
// send bad password
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'short',
}),
})
expect(response.status).to.equal(400)
// send good password
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'SomeThingVeryStrong!11',
}),
})
expect(response.status).to.equal(200)
userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog.length).to.equal(1)
})
it('when the password is the same as current, should return 400 and log the change', async function () {
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
}),
})
expect(response.status).to.equal(400)
const body = await response.json()
expect(body.message.key).to.equal('password-must-be-different')
userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog.length).to.equal(1)
})
})
})
describe('multiple attempts to set the password, reaching attempt limit', async function () {
beforeEach(async function () {
response = await userHelper.fetch(
`/user/password/set?passwordResetToken=${token}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url(`/user/password/set${emailQuery}`).toString()
)
})
it('should allow multiple attempts with same-password error, then deny further attempts', async function () {
const sendSamePasswordRequest = async function () {
return userHelper.fetch('/user/password/set', {
method: 'POST',
headers: {
Accept: 'application/json',
},
body: new URLSearchParams({
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
}),
})
}
// Three attempts at setting the password, all rejected for being the same as
// the current password
const response1 = await sendSamePasswordRequest()
expect(response1.status).to.equal(400)
const body1 = await response1.json()
expect(body1.message.key).to.equal('password-must-be-different')
const response2 = await sendSamePasswordRequest()
expect(response2.status).to.equal(400)
const body2 = await response2.json()
expect(body2.message.key).to.equal('password-must-be-different')
const response3 = await sendSamePasswordRequest()
expect(response3.status).to.equal(400)
const body3 = await response3.json()
expect(body3.message.key).to.equal('password-must-be-different')
// Fourth attempt is rejected because the token has been used too many times
const response4 = await sendSamePasswordRequest()
expect(response4.status).to.equal(404)
const body4 = await response4.json()
expect(body4.message.key).to.equal('token-expired')
})
it('should allow multiple attempts with same-password error, then set the password', async function () {
const sendSamePasswordRequest = async function () {
return userHelper.fetch('/user/password/set', {
method: 'POST',
headers: {
Accept: 'application/json',
},
body: new URLSearchParams({
passwordResetToken: token,
password: userHelper.getDefaultPassword(),
}),
})
}
// Two attempts at setting the password, all rejected for being the same as
// the current password
const response1 = await sendSamePasswordRequest()
expect(response1.status).to.equal(400)
const body1 = await response1.json()
expect(body1.message.key).to.equal('password-must-be-different')
const response2 = await sendSamePasswordRequest()
expect(response2.status).to.equal(400)
const body2 = await response2.json()
expect(body2.message.key).to.equal('password-must-be-different')
// Third attempt is succeeds
const response3 = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'some-new-password',
}),
})
expect(response3.status).to.equal(200)
// Check the user and audit log
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
expect(user.hashedPassword).to.exist
expect(user.password).to.not.exist
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.exist
expect(auditLog[0]).to.exist
expect(auditLog[0].initiatorId).to.equal(null)
expect(auditLog[0].operation).to.equal('reset-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('without a valid token', function () {
it('no token should redirect to page to re-request reset token', async function () {
response = await userHelper.fetch(`/user/password/set?&email=${email}`)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/user/password/reset').toString()
)
})
it('should show error for invalid tokens and return 404 if used', async function () {
const invalidToken = 'not-real-token'
response = await userHelper.fetch(
`/user/password/set?&passwordResetToken=${invalidToken}&email=${email}`
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/user/password/reset?error=token_expired').toString()
)
// send reset request
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: invalidToken,
password: 'a-password',
}),
})
expect(response.status).to.equal(404)
})
})
describe('password reset', function () {
it('should return 200 if email field is valid', async function () {
response = await userHelper.fetch(`/user/password/reset`, {
method: 'POST',
body: new URLSearchParams({ email }),
})
expect(response.status).to.equal(200)
})
it('should return 400 if email field is missing', async function () {
response = await userHelper.fetch(`/user/password/reset`, {
method: 'POST',
body: new URLSearchParams({ mail: email }),
})
expect(response.status).to.equal(400)
})
})
describe('password set', function () {
it('should return 200 if password and passwordResetToken fields are valid', async function () {
response = await userHelper.fetch(`/user/password/set`, {
method: 'POST',
body: new URLSearchParams({
password: 'new-password',
passwordResetToken: token,
}),
})
expect(response.status).to.equal(200)
})
it('should return 400 if password field is missing', async function () {
response = await userHelper.fetch(`/user/password/set`, {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
}),
})
expect(response.status).to.equal(400)
})
it('should return 400 if passwordResetToken field is missing', async function () {
response = await userHelper.fetch(`/user/password/set`, {
method: 'POST',
body: new URLSearchParams({
password: 'new-password',
}),
})
expect(response.status).to.equal(400)
})
})
describe('reconfirm flag', function () {
const getReconfirmAuditLogEntry = async function (email) {
const userHelper = await UserHelper.getUser({ email })
const auditLog = userHelper.getAuditLogWithoutNoise()
return auditLog.find(
entry => entry.operation === 'must-reset-password-unset'
)
}
it('should add audit log entry when flag changes from true to false', async function () {
// Set must_reconfirm to true
await db.users.updateOne(
{ _id: user._id },
{ $set: { must_reconfirm: true } }
)
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
expect(response.status).to.equal(200)
const reconfirmEntry = await getReconfirmAuditLogEntry(email)
expect(reconfirmEntry).to.exist
expect(reconfirmEntry.ipAddress).to.equal('127.0.0.1')
expect(reconfirmEntry.timestamp).to.exist
})
it('should not add audit log entry when flag was already false', async function () {
await db.users.updateOne(
{ _id: user._id },
{ $set: { must_reconfirm: false } }
)
response = await userHelper.fetch('/user/password/set', {
method: 'POST',
body: new URLSearchParams({
passwordResetToken: token,
password: 'a-password',
}),
})
expect(response.status).to.equal(200)
const reconfirmEntry = await getReconfirmAuditLogEntry(email)
expect(reconfirmEntry).to.not.exist
})
})
})

View File

@@ -0,0 +1,212 @@
import { expect } from 'chai'
import PasswordResetRouter from '../../../app/src/Features/PasswordReset/PasswordResetRouter.mjs'
import UserHelper from './helpers/UserHelper.mjs'
describe('PasswordUpdate', function () {
let email, password, response, user, userHelper
afterEach(async function () {
await PasswordResetRouter.rateLimiter.delete('127.0.0.1')
})
beforeEach(async function () {
userHelper = new UserHelper()
email = 'somecooluser@example.com'
password = 'old-password'
userHelper = await UserHelper.createUser({ email, password })
userHelper = await UserHelper.loginUser({
email,
password,
})
await userHelper.getCsrfToken()
})
describe('success', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
body: new URLSearchParams({
currentPassword: password,
newPassword1: 'new-password',
newPassword2: 'new-password',
}),
})
userHelper = await UserHelper.getUser({ email })
user = userHelper.user
})
it('should return 200', async function () {
expect(response.status).to.equal(200)
})
it('should update the audit log', function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog[0]).to.exist
expect(typeof auditLog[0].initiatorId).to.equal('object')
expect(auditLog[0].initiatorId).to.deep.equal(user._id)
expect(auditLog[0].operation).to.equal('update-password')
expect(auditLog[0].ipAddress).to.equal('127.0.0.1')
expect(auditLog[0].timestamp).to.exist
})
})
describe('errors', function () {
describe('missing current password', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
body: new URLSearchParams({
newPassword1: 'new-password',
newPassword2: 'new-password',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 500', async function () {
expect(response.status).to.equal(500)
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('wrong current password', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
body: new URLSearchParams({
currentPassword: 'wrong-password',
newPassword1: 'new-password',
newPassword2: 'new-password',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('newPassword1 does not match newPassword2', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
currentPassword: password,
newPassword1: 'new-password',
newPassword2: 'oops-password',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should return error message', async function () {
const body = await response.json()
expect(body.message).to.equal('Passwords do not match')
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('new password is not valid', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
currentPassword: password,
newPassword1: 'short',
newPassword2: 'short',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should return error message', async function () {
const body = await response.json()
expect(body.message).to.deep.equal({
type: 'error',
key: 'password-too-short',
text: 'Password too short, minimum 8',
})
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('new password contains part of email', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
currentPassword: password,
newPassword1: 'somecooluser123',
newPassword2: 'somecooluser123',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should return error message', async function () {
const body = await response.json()
expect(body.message).to.deep.equal({
key: 'password-contains-email',
type: 'error',
text: 'Password cannot contain parts of email address',
})
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
describe('new password is too similar to email', function () {
beforeEach(async function () {
response = await userHelper.fetch('/user/password/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
currentPassword: password,
newPassword1: 'coolusersome123',
newPassword2: 'coolusersome123',
}),
})
userHelper = await UserHelper.getUser({ email })
})
it('should return 400', async function () {
expect(response.status).to.equal(400)
})
it('should return error message', async function () {
const body = await response.json()
expect(body.message).to.deep.equal({
key: 'password-too-similar',
type: 'error',
text: 'Password is too similar to parts of email address',
})
})
it('should not update audit log', async function () {
const auditLog = userHelper.getAuditLogWithoutNoise()
expect(auditLog).to.deep.equal([])
})
})
})
})

View File

@@ -0,0 +1,332 @@
import UserHelper from './helpers/UserHelper.mjs'
import Settings from '@overleaf/settings'
import { expect } from 'chai'
import Features from '../../../app/src/infrastructure/Features.js'
import MockV1ApiClass from './mocks/MockV1Api.mjs'
import SubscriptionHelper from './helpers/Subscription.mjs'
const Subscription = SubscriptionHelper.promises
describe('PrimaryEmailCheck', function () {
let userHelper
let MockV1Api
before(function () {
MockV1Api = MockV1ApiClass.instance()
})
beforeEach(async function () {
userHelper = await UserHelper.createUser()
userHelper = await UserHelper.loginUser(
userHelper.getDefaultEmailPassword()
)
})
describe('redirections in Overleaf Community Edition/Server Pro', function () {
before(async function () {
if (Features.hasFeature('saas')) {
this.skip()
}
})
describe('when the user has signed up recently', function () {
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
it('should be redirected from the primary email check page to the project list', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has checked their email recently', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 0.5
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
})
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
})
describe('when the user has confirmed their primary email recently', function () {
beforeEach(async function () {
// the user should check again their email according to `lastPrimaryEmailCheck` timestamp, but the behaviour is
// overridden by email confirmation
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
await userHelper.confirmEmail(
userHelper.user._id,
userHelper.user.email
)
})
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
})
describe('when the user has signed for longer than the email check expiration period', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
})
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
})
})
describe('redirections in SAAS', function () {
before(async function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
})
describe('when the user has signed up recently', function () {
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
it('should be redirected from the primary email check page to the project list', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has checked their email recently', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 0.5
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
})
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
it('should be redirected from the primary email check page to the project list', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has confirmed their primary email recently', function () {
beforeEach(async function () {
// the user should check again their email according to `lastPrimaryEmailCheck` timestamp, but the behaviour is
// overridden by email confirmation
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
await userHelper.confirmEmail(
userHelper.user._id,
userHelper.user.email
)
})
it("shouldn't be redirected from project list to the primary email check page", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
it('should be redirected from the primary email check page to the project list', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has signed for longer than the email check expiration period', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
})
it('should be redirected from project list to the primary email check page', async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/user/emails/primary-email-check').toString()
)
})
it('can visit the primary email check page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(200)
})
})
})
describe('when user checks their primary email address', function () {
let checkResponse
beforeEach(async function () {
// make sure the user requires checking their primary email address
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
})
describe('when the user has a secondary email address', function () {
before(async function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
})
beforeEach(async function () {
await userHelper.confirmEmail(
userHelper.user._id,
userHelper.user.email
)
await userHelper.addEmailAndConfirm(
userHelper.user._id,
'secondary@overleaf.com'
)
checkResponse = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
})
it('should be redirected to the project list page', function () {
expect(checkResponse.status).to.equal(302)
expect(checkResponse.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has an institutional email and no secondary', function () {
before(async function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
if (!Features.hasFeature('saml')) {
this.skip()
}
})
beforeEach(async function () {
MockV1Api.createInstitution({
name: 'Exampe Institution',
hostname: 'example.com',
licence: 'pro_plus',
confirmed: true,
})
MockV1Api.addAffiliation(userHelper.user._id, userHelper.user.email)
})
it('should be redirected to the add secondary email page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/user/emails/add-secondary').toString()
)
})
})
describe('when the user is a managed user', function () {
beforeEach(async function () {
const adminUser = await UserHelper.createUser()
this.subscription = new Subscription({
adminId: adminUser._id,
memberIds: [userHelper.user._id],
groupPlan: true,
planCode: 'group_professional_5_enterprise',
})
await this.subscription.ensureExists()
await this.subscription.enableManagedUsers()
})
it('should be redirected to the project list page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
})
describe('when user has checked their primary email address', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
await userHelper.fetch('/user/emails/primary-email-check', {
method: 'POST',
})
})
it("shouldn't be redirected from project list to the primary email check page any longer", async function () {
const response = await userHelper.fetch('/project')
expect(response.status).to.equal(200)
})
it('visiting the primary email check page should redirect to the project list page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check'
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
})

View File

@@ -0,0 +1,192 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import { Project } from '../../../app/src/models/Project.js'
import mongodb from 'mongodb-legacy'
import cheerio from 'cheerio'
import { Subscription } from '../../../app/src/models/Subscription.js'
import Features from '../../../app/src/infrastructure/Features.js'
const ObjectId = mongodb.ObjectId
const User = UserHelper.promises
describe('Project CRUD', function () {
beforeEach(async function () {
this.user = new User()
await this.user.login()
this.projectId = await this.user.createProject('example-project')
})
describe('project page', function () {
const loadProject = async function (user, projectId) {
const { response, body } = await user.doRequest(
'GET',
`/project/${projectId}`
)
expect(response.statusCode).to.equal(200)
return body
}
it('should cast refProviders to booleans', async function () {
await this.user.mongoUpdate({
$set: {
refProviders: {
mendeley: { encrypted: 'aaa' },
zotero: { encrypted: 'bbb' },
},
},
})
const body = await loadProject(this.user, this.projectId)
const dom = cheerio.load(body)
const metaOlUser = dom('meta[name="ol-user"]')[0]
const userData = JSON.parse(metaOlUser.attribs.content)
expect(userData.refProviders.mendeley).to.equal(true)
expect(userData.refProviders.zotero).to.equal(true)
})
it('should show UpgradePrompt for user without a subscription', async function () {
const body = await loadProject(this.user, this.projectId)
expect(body).to.include(
Features.hasFeature('saas')
? // `content` means true in this context
'<meta name="ol-showUpgradePrompt" data-type="boolean" content>'
: '<meta name="ol-showUpgradePrompt" data-type="boolean">'
)
})
it('should not show UpgradePrompt for user with a subscription', async function () {
await Subscription.create({
admin_id: this.user._id,
manager_ids: [this.user._id],
})
const body = await loadProject(this.user, this.projectId)
// having no `content` means false in this context
expect(body).to.include(
'<meta name="ol-showUpgradePrompt" data-type="boolean">'
)
})
})
describe("when project doesn't exist", function () {
it('should return 404', async function () {
const { response } = await this.user.doRequest(
'GET',
'/project/aaaaaaaaaaaaaaaaaaaaaaaa'
)
expect(response.statusCode).to.equal(404)
})
})
describe('when project has malformed id', function () {
it('should return 404', async function () {
const { response } = await this.user.doRequest('GET', '/project/blah')
expect(response.statusCode).to.equal(404)
})
})
describe('when trashing a project', function () {
it('should mark the project as trashed for the user', async function () {
const { response } = await this.user.doRequest(
'POST',
`/project/${this.projectId}/trash`
)
expect(response.statusCode).to.equal(200)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.trashed, [this.user._id])
})
it('does nothing if the user has already trashed the project', async function () {
// Mark as trashed the first time
await this.user.doRequest('POST', `/project/${this.projectId}/trash`)
// And then a second time
await this.user.doRequest('POST', `/project/${this.projectId}/trash`)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.trashed, [this.user._id])
})
describe('with an array archived state', function () {
it('should mark the project as not archived for the user', async function () {
await Project.updateOne(
{ _id: this.projectId },
{ $set: { archived: [new ObjectId(this.user._id)] } }
).exec()
const { response } = await this.user.doRequest(
'POST',
`/project/${this.projectId}/trash`
)
expect(response.statusCode).to.equal(200)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.archived, [])
})
})
describe('with a legacy boolean state', function () {
it('should mark the project as not archived for the user', async function () {
await Project.updateOne(
{ _id: this.projectId },
{ $set: { archived: true } }
).exec()
const { response } = await this.user.doRequest(
'POST',
`/project/${this.projectId}/trash`
)
expect(response.statusCode).to.equal(200)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.archived, [])
})
})
})
describe('when untrashing a project', function () {
it('should mark the project as untrashed for the user', async function () {
await Project.updateOne(
{ _id: this.projectId },
{ trashed: [new ObjectId(this.user._id)] }
).exec()
const { response } = await this.user.doRequest(
'DELETE',
`/project/${this.projectId}/trash`
)
expect(response.statusCode).to.equal(200)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.trashed, [])
})
it('does nothing if the user has already untrashed the project', async function () {
await Project.updateOne(
{ _id: this.projectId },
{ trashed: [new ObjectId(this.user._id)] }
).exec()
// Mark as untrashed the first time
await this.user.doRequest('DELETE', `/project/${this.projectId}/trash`)
// And then a second time
await this.user.doRequest('DELETE', `/project/${this.projectId}/trash`)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.trashed, [])
})
it('sets trashed to an empty array if not set', async function () {
await this.user.doRequest('DELETE', `/project/${this.projectId}/trash`)
const trashedProject = await Project.findById(this.projectId).exec()
expectObjectIdArrayEqual(trashedProject.trashed, [])
})
})
})
function expectObjectIdArrayEqual(objectIdArray, stringArray) {
const stringifiedArray = objectIdArray.map(id => id.toString())
expect(stringifiedArray).to.deep.equal(stringArray)
}

View File

@@ -0,0 +1,696 @@
import { expect } from 'chai'
import sinon from 'sinon'
import Path from 'node:path'
import fs from 'node:fs'
import _ from 'lodash'
import User from './helpers/User.mjs'
import UserHelper from './helpers/UserHelper.mjs'
import MockDocstoreApiClass from './mocks/MockDocstoreApi.mjs'
import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs'
import MockV1HistoryApiClass from './mocks/MockV1HistoryApi.mjs'
import { fileURLToPath } from 'node:url'
import Features from '../../../app/src/infrastructure/Features.js'
let MockDocstoreApi, MockFilestoreApi, MockV1HistoryApi
const __dirname = fileURLToPath(new URL('.', import.meta.url))
before(function () {
MockDocstoreApi = MockDocstoreApiClass.instance()
MockFilestoreApi = MockFilestoreApiClass.instance()
MockV1HistoryApi = MockV1HistoryApiClass.instance()
})
describe('ProjectDuplicateNames', function () {
beforeEach(function (done) {
this.owner = new User()
this.owner.login(done)
this.project = {}
this.callback = sinon.stub()
})
describe('creating a project from the example template', function () {
beforeEach(function (done) {
this.owner.createProject(
'example-project',
{ template: 'example' },
(error, projectId) => {
expect(error).to.not.exist
this.example_project_id = projectId
this.owner.getProject(projectId, (error, project) => {
expect(error).to.not.exist
this.project = project
this.mainTexDoc = _.find(
project.rootFolder[0].docs,
doc => doc.name === 'main.tex'
)
this.refBibDoc = _.find(
project.rootFolder[0].docs,
doc => doc.name === 'sample.bib'
)
this.imageFile = _.find(
project.rootFolder[0].fileRefs,
file => file.name === 'frog.jpg'
)
this.rootFolderId = project.rootFolder[0]._id.toString()
// create a folder called 'testfolder'
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'testfolder',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.testFolderId = body._id
done()
}
)
})
}
)
})
it('should create a project', function () {
expect(this.project.rootFolder[0].docs.length).to.equal(2)
expect(this.project.rootFolder[0].fileRefs.length).to.equal(1)
})
it('should create two docs in the docstore', function () {
const docs = MockDocstoreApi.docs[this.example_project_id]
expect(Object.keys(docs).length).to.equal(2)
})
if (Features.hasFeature('project-history-blobs')) {
it('should create one file in the history-v1', function () {
const files =
MockV1HistoryApi.blobs[this.project.overleaf.history.id.toString()]
expect(Object.keys(files).length).to.equal(1)
})
}
if (Features.hasFeature('filestore')) {
it('should create one file in the filestore', function () {
const files = MockFilestoreApi.files[this.example_project_id]
expect(Object.keys(files).length).to.equal(1)
})
}
describe('for an existing doc', function () {
describe('trying to add a doc with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc`,
json: {
name: 'main.tex',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to add a folder with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'main.tex',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
describe('for an existing file', function () {
describe('trying to add a doc with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc`,
json: {
name: 'frog.jpg',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to add a folder with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'frog.jpg',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to upload a file with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/upload`,
json: true,
qs: {
folder_id: this.rootFolderId,
qqfilename: 'frog.jpg',
},
formData: {
name: 'frog.jpg',
qqfile: {
value: fs.createReadStream(
Path.join(__dirname, '/../files/1pixel.png')
),
options: {
filename: 'frog.jpg',
contentType: 'image/jpeg',
},
},
},
},
(err, res, body) => {
expect(err).to.not.exist
this.body = body
// update the image id because we have replaced the file
this.imageFile._id = this.body.entity_id
done()
}
)
})
it('should succeed (overwriting the file)', function () {
expect(this.body.success).to.equal(true)
})
})
})
// at this point the @imageFile._id has changed
describe('for an existing folder', function () {
describe('trying to add a doc with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc`,
json: {
name: 'testfolder',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to add a folder with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'testfolder',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to upload a file with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/upload`,
json: true,
qs: {
folder_id: this.rootFolderId,
qqfilename: 'frog.jpg',
},
formData: {
qqfile: {
value: fs.createReadStream(
Path.join(__dirname, '/../files/1pixel.png')
),
options: {
filename: 'testfolder',
contentType: 'image/jpeg',
},
},
},
},
(err, res, body) => {
expect(err).to.not.exist
this.body = body
done()
}
)
})
it('should respond with failure status', function () {
expect(this.body.success).to.equal(false)
})
})
})
describe('rename for an existing doc', function () {
describe('trying to rename a doc to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc/${this.refBibDoc._id}/rename`,
json: {
name: 'main.tex',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a folder to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder/${this.testFolderId}/rename`,
json: {
name: 'main.tex',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a file to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/file/${this.imageFile._id}/rename`,
json: {
name: 'main.tex',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with failure status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
describe('rename for an existing file', function () {
describe('trying to rename a doc to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc/${this.refBibDoc._id}/rename`,
json: {
name: 'frog.jpg',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a folder to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder/${this.testFolderId}/rename`,
json: {
name: 'frog.jpg',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a file to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/file/${this.imageFile._id}/rename`,
json: {
name: 'frog.jpg',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with failure status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
describe('rename for an existing folder', function () {
describe('trying to rename a doc to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc/${this.refBibDoc._id}/rename`,
json: {
name: 'testfolder',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a folder to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder/${this.testFolderId}/rename`,
json: {
name: 'testfolder',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to rename a file to the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/file/${this.imageFile._id}/rename`,
json: {
name: 'testfolder',
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with failure status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
describe('for an existing folder with a file with the same name', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc`,
json: {
name: 'main.tex',
parent_folder_id: this.testFolderId,
},
},
(err, res, body) => {
if (err) {
throw err
}
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc`,
json: {
name: 'frog.jpg',
parent_folder_id: this.testFolderId,
},
},
(err, res, body) => {
if (err) {
throw err
}
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'otherFolder',
parent_folder_id: this.testFolderId,
},
},
(err, res, body) => {
if (err) {
throw err
}
this.subFolderId = body._id
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder`,
json: {
name: 'otherFolder',
parent_folder_id: this.rootFolderId,
},
},
(err, res, body) => {
if (err) {
throw err
}
this.otherFolderId = body._id
done()
}
)
}
)
}
)
}
)
})
describe('trying to move a doc into the folder', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/doc/${this.mainTexDoc._id}/move`,
json: {
folder_id: this.testFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to move a file into the folder', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/file/${this.imageFile._id}/move`,
json: {
folder_id: this.testFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to move a folder into the folder', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder/${this.otherFolderId}/move`,
json: {
folder_id: this.testFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
describe('trying to move a folder into a subfolder of itself', function () {
beforeEach(function (done) {
this.owner.request.post(
{
uri: `/project/${this.example_project_id}/folder/${this.testFolderId}/move`,
json: {
folder_id: this.subFolderId,
},
},
(err, res, body) => {
expect(err).to.not.exist
this.res = res
done()
}
)
})
it('should respond with 400 error status', function () {
expect(this.res.statusCode).to.equal(400)
})
})
})
})
describe('regex characters in title', function () {
let response, userHelper
beforeEach(async function () {
userHelper = new UserHelper()
userHelper = await UserHelper.createUser()
userHelper = await UserHelper.loginUser(
userHelper.getDefaultEmailPassword()
)
})
it('should handle characters that would cause an invalid regular expression', async function () {
const projectName = 'Example (test'
response = await userHelper.fetch('/project/new', {
method: 'POST',
body: new URLSearchParams([['projectName', projectName]]),
})
const body = await response.json()
expect(response.status).to.equal(200) // can create project
response = await userHelper.fetch(`/project/${body.project_id}`)
expect(response.status).to.equal(200) // can open project
})
})
})

View File

@@ -0,0 +1,97 @@
/* eslint-disable
n/handle-callback-err,
max-len,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
import request from './helpers/request.js'
import settings from '@overleaf/settings'
const joinProject = (userId, projectId, callback) =>
request.post(
{
url: `/project/${projectId}/join`,
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
json: { userId },
jar: false,
},
callback
)
describe('ProjectFeatures', function () {
beforeEach(function (done) {
this.timeout(90000)
this.owner = new User()
return async.series([cb => this.owner.login(cb)], done)
})
describe('with private project', function () {
beforeEach(function (done) {
return this.owner.createProject('private-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.project_id = projectId
return done()
})
})
describe('with an upgraded account', function () {
beforeEach(function (done) {
return this.owner.upgradeSomeFeatures(done)
})
after(function (done) {
return this.owner.defaultFeatures(done)
})
it('should have premium features', function (done) {
return joinProject(
this.owner._id,
this.project_id,
(error, response, body) => {
expect(body.project.features.compileGroup).to.equal('priority')
expect(body.project.features.versioning).to.equal(true)
expect(body.project.features.dropbox).to.equal(true)
return done()
}
)
})
})
describe('with an basic account', function () {
beforeEach(function (done) {
return this.owner.downgradeFeatures(done)
})
after(function (done) {
return this.owner.defaultFeatures(done)
})
it('should have basic features', function (done) {
return joinProject(
this.owner._id,
this.project_id,
(error, response, body) => {
expect(body.project.features.compileGroup).to.equal('standard')
expect(body.project.features.versioning).to.equal(false)
expect(body.project.features.dropbox).to.equal(false)
return done()
}
)
})
})
})
})

View File

@@ -0,0 +1,865 @@
import { expect } from 'chai'
import Async from 'async'
import User from './helpers/User.mjs'
import settings from '@overleaf/settings'
import CollaboratorsEmailHandler from '../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs'
import CollaboratorsInviteHelper from '../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.js'
import Features from '../../../app/src/infrastructure/Features.js'
import cheerio from 'cheerio'
import sinon from 'sinon'
let generateTokenSpy
const createInvite = (sendingUser, projectId, email, callback) => {
sendingUser.getCsrfToken(err => {
if (err) {
return callback(err)
}
sendingUser.request.post(
{
uri: `/project/${projectId}/invite`,
json: {
email,
privileges: 'readAndWrite',
},
},
(err, response, body) => {
if (err) {
return callback(err)
}
expect(response.statusCode).to.equal(200)
expect(body.error).to.not.exist
expect(body.invite).to.exist
callback(null, body.invite)
}
)
})
}
const createProject = (owner, projectName, callback) => {
owner.createProject(projectName, (err, projectId) => {
if (err) {
throw err
}
const fakeProject = {
_id: projectId,
name: projectName,
owner_ref: owner,
}
callback(err, projectId, fakeProject)
})
}
const createProjectAndInvite = (owner, projectName, email, callback) => {
createProject(owner, projectName, (err, projectId, project) => {
if (err) {
return callback(err)
}
createInvite(owner, projectId, email, (err, invite) => {
if (err) {
return callback(err)
}
// attach the token to the invite
invite.token = generateTokenSpy.getCall(0).returnValue
const link = CollaboratorsEmailHandler._buildInviteUrl(project, invite)
callback(null, project, invite, link)
})
})
}
const revokeInvite = (sendingUser, projectId, inviteId, callback) => {
sendingUser.getCsrfToken(err => {
if (err) {
return callback(err)
}
sendingUser.request.delete(
{
uri: `/project/${projectId}/invite/${inviteId}`,
},
err => {
if (err) {
return callback(err)
}
callback()
}
)
})
}
// Actions
const tryFollowInviteLink = (user, link, callback) => {
user.request.get(
{
uri: link,
baseUrl: null,
},
callback
)
}
const tryAcceptInvite = (user, invite, projectId, callback) => {
user.getCsrfToken(err => {
if (err) {
return callback(err)
}
user.request.post(
{
uri: `/project/${projectId}/invite/token/${invite.token}/accept`,
json: {
token: invite.token,
},
},
callback
)
})
}
const tryFollowLoginLink = (user, loginLink, callback) => {
user.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
user.request.get(loginLink, callback)
})
}
const tryLoginUser = (user, callback) => {
user.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
user.request.post(
{
url: '/login',
json: {
email: user.email,
password: user.password,
'g-recaptcha-response': 'valid',
},
},
callback
)
})
}
const tryGetInviteList = (user, projectId, callback) => {
user.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
user.request.get(
{
url: `/project/${projectId}/invites`,
json: true,
},
callback
)
})
}
const tryJoinProject = (user, projectId, callback) => {
return user.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
user.request.post(
{
url: `/project/${projectId}/join`,
auth: {
user: settings.apis.web.user,
pass: settings.apis.web.pass,
sendImmediately: true,
},
json: { userId: user._id },
jar: false,
},
callback
)
})
}
// Expectations
const expectProjectAccess = (user, projectId, callback) => {
// should have access to project
user.openProject(projectId, err => {
expect(err).not.to.exist
callback()
})
}
const expectNoProjectAccess = (user, projectId, callback) => {
// should not have access to project page
user.openProject(projectId, err => {
expect(err).to.be.instanceof(Error)
callback()
})
}
const expectInvitePage = (user, link, callback) => {
// view invite
tryFollowInviteLink(user, link, (err, response, body) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
expect(body).to.match(/<title>Project Invite - .*<\/title>/)
callback()
})
}
const expectInvalidInvitePage = (user, link, callback) => {
// view invalid invite
tryFollowInviteLink(user, link, (err, response, body) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(404)
expect(body).to.match(/<title>Invalid Invite - .*<\/title>/)
callback()
})
}
const expectInviteRedirectToRegister = (user, link, callback) => {
// view invite, redirect to `/register`
tryFollowInviteLink(user, link, (err, response) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register.*$/)
user.getSession((err, session) => {
if (err) return callback(err)
expect(session.sharedProjectData).deep.equals({
project_name: PROJECT_NAME,
user_first_name: OWNER_NAME,
})
callback()
})
})
}
const expectLoginPage = (user, callback) => {
tryFollowLoginLink(user, '/login', (err, response, body) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
expect(body).to.match(/<title>(Login|Log in to Overleaf) - .*<\/title>/)
callback()
})
}
const expectLoginRedirectToInvite = (user, link, callback) => {
tryLoginUser(user, (err, response) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
callback()
})
}
const expectRegistrationRedirectToInvite = (user, link, callback) => {
user.register((err, _user, response) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
if (response.body.redir === '/registration/try-premium') {
user.request.get('/registration/onboarding', (err, response) => {
if (err) return callback(err)
expect(response.statusCode).to.equal(200)
const dom = cheerio.load(response.body)
const skipUrl = dom('meta[name="ol-skipUrl"]')[0].attribs.content
expect(new URL(skipUrl, settings.siteUrl).href).to.equal(
new URL(link, settings.siteUrl).href
)
callback()
})
} else {
expect(response.body.redir).to.equal(link)
callback()
}
})
}
const expectInviteRedirectToProject = (user, link, projectId, callback) => {
// view invite, redirect straight to project
tryFollowInviteLink(user, link, (err, response) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(`/project/${projectId}`)
callback()
})
}
const expectAcceptInviteAndRedirect = (user, invite, projectId, callback) => {
// should accept the invite and redirect to project
tryAcceptInvite(user, invite, projectId, (err, response) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(`/project/${projectId}`)
callback()
})
}
const expectInviteListCount = (user, projectId, count, callback) => {
tryGetInviteList(user, projectId, (err, response, body) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
expect(body).to.have.all.keys(['invites'])
expect(body.invites.length).to.equal(count)
callback()
})
}
const expectInvitesInJoinProjectCount = (user, projectId, count, callback) => {
tryJoinProject(user, projectId, (err, response, body) => {
expect(err).not.to.exist
expect(response.statusCode).to.equal(200)
expect(body.project).to.contain.keys(['invites'])
expect(body.project.invites.length).to.equal(count)
callback()
})
}
const PROJECT_NAME = 'project name for sharing test'
const OWNER_NAME = 'sending user name'
describe('ProjectInviteTests', function () {
beforeEach(function (done) {
this.sendingUser = new User()
this.user = new User()
this.site_admin = new User({ email: `admin+${Math.random()}@example.com` })
this.email = `smoketestuser+${Math.random()}@example.com`
generateTokenSpy = sinon.spy(CollaboratorsInviteHelper, 'generateToken')
Async.series(
[
cb => this.sendingUser.ensureUserExists(cb),
cb => this.sendingUser.upgradeFeatures({ collaborators: 10 }, cb),
cb => this.sendingUser.login(cb),
cb =>
this.sendingUser.mongoUpdate(
{
$set: { first_name: OWNER_NAME },
},
cb
),
],
done
)
})
afterEach(function () {
generateTokenSpy.restore()
})
describe('creating invites', function () {
describe('creating two invites', function () {
beforeEach(function (done) {
createProject(
this.sendingUser,
PROJECT_NAME,
(err, projectId, project) => {
expect(err).not.to.exist
this.projectId = projectId
this.fakeProject = project
done()
}
)
})
it('should fail if email is not a string', function (done) {
this.sendingUser.getCsrfToken(err => {
if (err) {
return done(err)
}
this.sendingUser.request.post(
{
uri: `/project/${this.projectId}/invite`,
json: {
email: {},
privileges: 'readAndWrite',
},
},
(err, response, body) => {
if (err) {
return done(err)
}
expect(response.statusCode).to.equal(400)
expect(response.body.validation.body.message).to.equal(
'"email" must be a string'
)
done()
}
)
})
})
it('should fail on invalid privileges', function (done) {
this.sendingUser.getCsrfToken(err => {
if (err) {
return done(err)
}
this.sendingUser.request.post(
{
uri: `/project/${this.projectId}/invite`,
json: {
email: this.email,
privileges: 'invalid-privilege',
},
},
(err, response, body) => {
if (err) {
return done(err)
}
expect(response.statusCode).to.equal(400)
expect(response.body.validation.body.message).to.equal(
'"privileges" must be one of [readOnly, readAndWrite, review]'
)
done()
}
)
})
})
it('should allow the project owner to create and remove invites', function (done) {
Async.series(
[
cb => expectProjectAccess(this.sendingUser, this.projectId, cb),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
// create invite, check invite list count
cb => {
createInvite(
this.sendingUser,
this.projectId,
this.email,
(err, invite) => {
if (err) {
return cb(err)
}
this.invite = invite
cb()
}
)
},
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 1, cb),
cb =>
revokeInvite(
this.sendingUser,
this.projectId,
this.invite._id,
cb
),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
// and a second time
cb => {
createInvite(
this.sendingUser,
this.projectId,
this.email,
(err, invite) => {
if (err) {
return cb(err)
}
this.invite = invite
cb()
}
)
},
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 1, cb),
// check the joinProject view
cb =>
expectInvitesInJoinProjectCount(
this.sendingUser,
this.projectId,
1,
cb
),
// revoke invite
cb =>
revokeInvite(
this.sendingUser,
this.projectId,
this.invite._id,
cb
),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
cb =>
expectInvitesInJoinProjectCount(
this.sendingUser,
this.projectId,
0,
cb
),
],
done
)
})
it('should allow the project owner to create many invites at once', function (done) {
Async.series(
[
cb => expectProjectAccess(this.sendingUser, this.projectId, cb),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
// create first invite
cb => {
createInvite(
this.sendingUser,
this.projectId,
this.email,
(err, invite) => {
if (err) {
return cb(err)
}
this.inviteOne = invite
cb()
}
)
},
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 1, cb),
// and a second
cb => {
createInvite(
this.sendingUser,
this.projectId,
this.email,
(err, invite) => {
if (err) {
return cb(err)
}
this.inviteTwo = invite
cb()
}
)
},
// should have two
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 2, cb),
cb =>
expectInvitesInJoinProjectCount(
this.sendingUser,
this.projectId,
2,
cb
),
// revoke first
cb =>
revokeInvite(
this.sendingUser,
this.projectId,
this.inviteOne._id,
cb
),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 1, cb),
// revoke second
cb =>
revokeInvite(
this.sendingUser,
this.projectId,
this.inviteTwo._id,
cb
),
cb =>
expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
],
done
)
})
})
})
describe('clicking the invite link', function () {
beforeEach(function (done) {
createProjectAndInvite(
this.sendingUser,
PROJECT_NAME,
this.email,
(err, project, invite, link) => {
expect(err).not.to.exist
this.projectId = project._id
this.fakeProject = project
this.invite = invite
this.link = link
done()
}
)
})
describe('user is logged in already', function () {
beforeEach(function (done) {
this.user.login(done)
})
describe('user is already a member of the project', function () {
beforeEach(function (done) {
Async.series(
[
cb => expectInvitePage(this.user, this.link, cb),
cb =>
expectAcceptInviteAndRedirect(
this.user,
this.invite,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
],
done
)
})
describe('when user clicks on the invite a second time', function () {
it('should just redirect to the project page', function (done) {
Async.series(
[
cb => expectProjectAccess(this.user, this.projectId, cb),
cb =>
expectInviteRedirectToProject(
this.user,
this.link,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
],
done
)
})
describe('when the user recieves another invite to the same project', function () {
it('should redirect to the project page', function (done) {
Async.series(
[
cb => {
createInvite(
this.sendingUser,
this.projectId,
this.email,
(err, invite) => {
if (err) {
throw err
}
this.secondInvite = invite
const token = generateTokenSpy.getCall(1).returnValue
this.secondLink =
CollaboratorsEmailHandler._buildInviteUrl(
this.fakeProject,
{ ...invite, token }
)
cb()
}
)
},
cb =>
expectInviteRedirectToProject(
this.user,
this.secondLink,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
cb =>
revokeInvite(
this.sendingUser,
this.projectId,
this.secondInvite._id,
cb
),
],
done
)
})
})
})
})
describe('user is not a member of the project', function () {
it('should not grant access if the user does not accept the invite', function (done) {
Async.series(
[
cb => expectInvitePage(this.user, this.link, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should render the invalid-invite page if the token is invalid', function (done) {
Async.series(
[
cb => {
const link = this.link.replace(
this.invite.token,
'not_a_real_token'
)
expectInvalidInvitePage(this.user, link, cb)
},
cb => expectNoProjectAccess(this.user, this.projectId, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should allow the user to accept the invite and access the project', function (done) {
Async.series(
[
cb => expectInvitePage(this.user, this.link, cb),
cb =>
expectAcceptInviteAndRedirect(
this.user,
this.invite,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
],
done
)
})
})
})
describe('user is not logged in initially', function () {
describe('registration prompt workflow with valid token', function () {
before(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
it('should redirect to the register page', function (done) {
expectInviteRedirectToRegister(this.user, this.link, done)
})
it('should allow user to accept the invite if the user registers a new account', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb =>
expectRegistrationRedirectToInvite(this.user, this.link, cb),
cb => expectInvitePage(this.user, this.link, cb),
cb =>
expectAcceptInviteAndRedirect(
this.user,
this.invite,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
],
done
)
})
})
describe('registration prompt workflow with non-valid token', function () {
before(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
it('should redirect to the register page', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should display invalid-invite right away', function (done) {
const badLink = this.link.replace(
this.invite.token,
'not_a_real_token'
)
Async.series(
[
cb => expectInvalidInvitePage(this.user, badLink, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
})
describe('login workflow with valid token', function () {
beforeEach(function (done) {
this.user.ensureUserExists(done)
})
it('should redirect to the register page', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should allow the user to login to view the invite', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => expectLoginPage(this.user, cb),
cb => expectLoginRedirectToInvite(this.user, this.link, cb),
cb => expectInvitePage(this.user, this.link, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should allow user to accept the invite if the user logs in', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => expectLoginPage(this.user, cb),
cb => expectLoginRedirectToInvite(this.user, this.link, cb),
cb => expectInvitePage(this.user, this.link, cb),
cb =>
expectAcceptInviteAndRedirect(
this.user,
this.invite,
this.projectId,
cb
),
cb => expectProjectAccess(this.user, this.projectId, cb),
],
done
)
})
})
describe('login workflow with non-valid token', function () {
it('should redirect to the register page', function (done) {
Async.series(
[
cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
it('should show the invalid-invite page right away', function (done) {
const badLink = this.link.replace(
this.invite.token,
'not_a_real_token'
)
Async.series(
[
cb => expectInvalidInvitePage(this.user, badLink, cb),
cb => expectNoProjectAccess(this.user, this.projectId, cb),
],
done
)
})
})
})
})
})

View File

@@ -0,0 +1,124 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('Project ownership transfer', function () {
beforeEach(async function () {
this.ownerSession = new User()
this.collaboratorSession = new User()
this.strangerSession = new User()
this.adminSession = new User()
this.invitedAdminSession = new User()
await this.invitedAdminSession.ensureUserExists()
await this.invitedAdminSession.ensureAdmin()
await this.invitedAdminSession.login()
await this.adminSession.ensureUserExists()
await this.adminSession.ensureAdmin()
await this.ownerSession.login()
await this.collaboratorSession.login()
await this.strangerSession.login()
await this.adminSession.login()
this.owner = await this.ownerSession.get()
this.collaborator = await this.collaboratorSession.get()
this.stranger = await this.strangerSession.get()
this.admin = await this.adminSession.get()
this.invitedAdmin = await this.invitedAdminSession.get()
this.projectId = await this.ownerSession.createProject('Test project')
await this.ownerSession.addUserToProject(
this.projectId,
this.invitedAdmin,
'readAndWrite'
)
await this.ownerSession.addUserToProject(
this.projectId,
this.collaborator,
'readAndWrite'
)
})
describe('happy path', function () {
beforeEach(async function () {
await this.ownerSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
})
it('changes the project owner', async function () {
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
})
it('adds the previous owner as a read/write collaborator', async function () {
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.collaberator_refs.map(x => x.toString())).to.have.members([
this.owner._id.toString(),
this.invitedAdmin._id.toString(),
])
})
it('lets the new owner open the project', async function () {
await this.collaboratorSession.openProject(this.projectId)
})
it('lets the previous owner open the project', async function () {
await this.ownerSession.openProject(this.projectId)
})
})
describe('ownership change as admin', function () {
it('lets the invited admin transfer ownership', async function () {
await this.invitedAdminSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
const project = await this.invitedAdminSession.getProject(this.projectId)
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
})
it('lets the non-invited admin transfer ownership', async function () {
await this.adminSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
const project = await this.adminSession.getProject(this.projectId)
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
})
})
describe('validation', function () {
it('lets only the project owner transfer ownership', async function () {
await expect(
this.collaboratorSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
).to.be.rejectedWith(/failed: status=403 /)
})
it('prevents transfers to a non-collaborator', async function () {
await expect(
this.ownerSession.transferProjectOwnership(
this.projectId,
this.stranger._id
)
).to.be.rejectedWith(/failed: status=403 /)
})
it('allows an admin to transfer to any project to a non-collaborator', async function () {
await expect(
this.adminSession.transferProjectOwnership(
this.projectId,
this.stranger._id
)
).to.be.fulfilled
})
})
})

View File

@@ -0,0 +1,176 @@
/* eslint-disable
n/handle-callback-err,
max-len,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import LockManager from '../../../app/src/infrastructure/LockManager.js'
import ProjectCreationHandler from '../../../app/src/Features/Project/ProjectCreationHandler.js'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
import ProjectEntityMongoUpdateHandler from '../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js'
import UserCreator from '../../../app/src/Features/User/UserCreator.js'
import { expect } from 'chai'
import _ from 'lodash'
// These tests are neither acceptance tests nor unit tests. It's difficult to
// test/verify that our locking is doing what we hope.
// These tests call methods in ProjectGetter and ProjectEntityMongoUpdateHandler
// to see that they DO NOT work when a lock has been taken.
//
// It is tested that these methods DO work when the lock has not been taken in
// other acceptance tests.
describe('ProjectStructureMongoLock', function () {
describe('whilst a project lock is taken', function () {
let oldMaxLockWaitTime
before(function () {
oldMaxLockWaitTime = LockManager.MAX_LOCK_WAIT_TIME
})
after(function () {
LockManager.MAX_LOCK_WAIT_TIME = oldMaxLockWaitTime
})
beforeEach(function (done) {
// We want to instantly fail if the lock is taken
LockManager.MAX_LOCK_WAIT_TIME = 1
this.lockValue = 'lock-value'
const userDetails = {
holdingAccount: false,
email: 'test@example.com',
}
UserCreator.createNewUser(userDetails, {}, (err, user) => {
this.user = user
if (err != null) {
throw err
}
return ProjectCreationHandler.createBlankProject(
user._id,
'locked-project',
(err, project) => {
if (err != null) {
throw err
}
this.locked_project = project
const namespace = ProjectEntityMongoUpdateHandler.LOCK_NAMESPACE
this.lock_key = `lock:web:${namespace}:${project._id}`
return LockManager._getLock(
this.lock_key,
namespace,
(err, lockValue) => {
this.lockValue = lockValue
return done()
}
)
}
)
})
})
afterEach(function (done) {
return LockManager._releaseLock(this.lock_key, this.lockValue, done)
})
describe('interacting with the locked project', function () {
const LOCKING_UPDATE_METHODS = [
'addDoc',
'addFile',
'mkdirp',
'moveEntity',
'renameEntity',
'addFolder',
]
for (const methodName of Array.from(LOCKING_UPDATE_METHODS)) {
it(`cannot call ProjectEntityMongoUpdateHandler.${methodName}`, function (done) {
const method = ProjectEntityMongoUpdateHandler[methodName]
const args = _.times(method.length - 2, _.constant(null))
return method(this.locked_project._id, args, err => {
expect(err).to.be.instanceOf(Error)
expect(err).to.have.property('message', 'Timeout')
return done()
})
})
}
it('cannot get the project without a projection', function (done) {
return ProjectGetter.getProject(this.locked_project._id, err => {
expect(err).to.be.instanceOf(Error)
expect(err).to.have.property('message', 'Timeout')
return done()
})
})
it('cannot get the project if rootFolder is in the projection', function (done) {
return ProjectGetter.getProject(
this.locked_project._id,
{ rootFolder: true },
err => {
expect(err).to.be.instanceOf(Error)
expect(err).to.have.property('message', 'Timeout')
return done()
}
)
})
it('can get the project if rootFolder is not in the projection', function (done) {
return ProjectGetter.getProject(
this.locked_project._id,
{ _id: true },
(err, project) => {
expect(err).to.equal(null)
expect(project._id).to.deep.equal(this.locked_project._id)
return done()
}
)
})
})
describe('interacting with other projects', function () {
beforeEach(function (done) {
return ProjectCreationHandler.createBlankProject(
this.user._id,
'unlocked-project',
(err, project) => {
if (err != null) {
throw err
}
this.unlocked_project = project
return done()
}
)
})
it('can add folders to other projects', function (done) {
return ProjectEntityMongoUpdateHandler.addFolder(
this.unlocked_project._id,
this.unlocked_project.rootFolder[0]._id,
'new folder',
(err, folder) => {
expect(err).to.equal(null)
expect(folder).to.exist
return done()
}
)
})
it('can get other projects without a projection', function (done) {
return ProjectGetter.getProject(
this.unlocked_project._id,
(err, project) => {
expect(err).to.equal(null)
expect(project._id).to.deep.equal(this.unlocked_project._id)
return done()
}
)
})
})
})
})

View File

@@ -0,0 +1,317 @@
import chai, { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import Path from 'node:path'
import fs from 'node:fs'
import { Project } from '../../../app/src/models/Project.js'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
import UserHelper from './helpers/User.mjs'
import MockDocStoreApiClass from './mocks/MockDocstoreApi.mjs'
import MockDocUpdaterApiClass from './mocks/MockDocUpdaterApi.mjs'
import chaiAsPromised from 'chai-as-promised'
chai.use(chaiAsPromised)
const User = UserHelper.promises
const ObjectId = mongodb.ObjectId
let MockDocStoreApi, MockDocUpdaterApi
before(function () {
MockDocUpdaterApi = MockDocUpdaterApiClass.instance()
MockDocStoreApi = MockDocStoreApiClass.instance()
})
describe('ProjectStructureChanges', function () {
let owner
beforeEach(async function () {
owner = new User()
await owner.login()
})
async function createExampleProject(owner) {
const projectId = await owner.createProject('example-project', {
template: 'example',
})
const project = await ProjectGetter.promises.getProject(projectId)
const rootFolderId = project.rootFolder[0]._id.toString()
return { projectId, rootFolderId }
}
async function createExampleDoc(owner, projectId) {
const project = await ProjectGetter.promises.getProject(projectId)
const { response, body } = await owner.doRequest('POST', {
uri: `project/${projectId}/doc`,
json: {
name: 'new.tex',
parent_folder_id: project.rootFolder[0]._id,
},
})
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`failed to add doc ${response.statusCode}`)
}
return body._id
}
async function createExampleFolder(owner, projectId) {
const { response, body } = await owner.doRequest('POST', {
uri: `project/${projectId}/folder`,
json: {
name: 'foo',
},
})
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`failed to add doc ${response.statusCode}`)
}
return body._id
}
async function uploadExampleProject(owner, zipFilename, options = {}) {
const zipFile = fs.createReadStream(
Path.resolve(Path.join(import.meta.dirname, '..', 'files', zipFilename))
)
const { response, body } = await owner.doRequest('POST', {
uri: 'project/new/upload',
formData: {
name: zipFilename,
qqfile: zipFile,
},
})
if (
!options.allowBadStatus &&
(response.statusCode < 200 || response.statusCode >= 300)
) {
throw new Error(`failed to upload project ${response.statusCode}`)
}
return { projectId: JSON.parse(body).project_id, response }
}
async function deleteItem(owner, projectId, type, itemId) {
return await owner.deleteItemInProject(projectId, type, itemId)
}
describe('uploading a project with a name', function () {
let exampleProjectId
const testProjectName = 'wombat'
beforeEach(async function () {
const { projectId } = await uploadExampleProject(
owner,
'test_project_with_name.zip'
)
exampleProjectId = projectId
})
it('should set the project name from the zip contents', async function () {
const project = await ProjectGetter.promises.getProject(exampleProjectId)
expect(project.name).to.equal(testProjectName)
})
})
describe('uploading a project with an invalid name', function () {
let exampleProjectId
const testProjectMatch = /^bad[^\\]+name$/
beforeEach(async function () {
const { projectId } = await uploadExampleProject(
owner,
'test_project_with_invalid_name.zip'
)
exampleProjectId = projectId
})
it('should set the project name from the zip contents', async function () {
const project = await ProjectGetter.promises.getProject(exampleProjectId)
expect(project.name).to.match(testProjectMatch)
})
})
describe('uploading an empty zipfile', function () {
let res
beforeEach(async function () {
const { response } = await uploadExampleProject(
owner,
'test_project_empty.zip',
{ allowBadStatus: true }
)
res = response
})
it('should fail with 422 error', function () {
expect(res.statusCode).to.equal(422)
})
})
describe('uploading a zipfile containing only empty directories', function () {
let res
beforeEach(async function () {
const { response } = await uploadExampleProject(
owner,
'test_project_with_empty_folder.zip',
{ allowBadStatus: true }
)
res = response
})
it('should fail with 422 error', function () {
expect(res.statusCode).to.equal(422)
})
})
describe('uploading a project with a shared top-level folder', function () {
let exampleProjectId
beforeEach(async function () {
const { projectId } = await uploadExampleProject(
owner,
'test_project_with_shared_top_level_folder.zip'
)
exampleProjectId = projectId
})
it('should not create the top-level folder', async function () {
const project = await ProjectGetter.promises.getProject(exampleProjectId)
expect(project.rootFolder[0].folders.length).to.equal(0)
expect(project.rootFolder[0].docs.length).to.equal(2)
})
})
describe('uploading a project with backslashes in the path names', function () {
let exampleProjectId
beforeEach(async function () {
const { projectId } = await uploadExampleProject(
owner,
'test_project_with_backslash_in_filename.zip'
)
exampleProjectId = projectId
})
it('should treat the backslash as a directory separator', async function () {
const project = await ProjectGetter.promises.getProject(exampleProjectId)
expect(project.rootFolder[0].folders[0].name).to.equal('styles')
expect(project.rootFolder[0].folders[0].docs[0].name).to.equal('ao.sty')
})
})
describe('deleting folders', function () {
beforeEach(async function () {
const { projectId } = await createExampleProject(owner)
this.exampleProjectId = projectId
})
describe('when the folder is the rootFolder', function () {
beforeEach(async function () {
const project = await ProjectGetter.promises.getProject(
this.exampleProjectId
)
this.rootFolderId = project.rootFolder[0]._id
})
it('should fail with a 422 error', async function () {
await expect(
deleteItem(owner, this.exampleProjectId, 'folder', this.rootFolderId)
)
.to.be.rejected.and.eventually.match(/status=422/)
.and.eventually.match(/body="cannot delete root folder"/)
})
})
describe('when the folder is not the rootFolder', function () {
beforeEach(async function () {
const folderId = await createExampleFolder(owner, this.exampleProjectId)
this.exampleFolderId = folderId
})
it('should succeed', async function () {
await expect(
deleteItem(
owner,
this.exampleProjectId,
'folder',
this.exampleFolderId
)
).to.be.fulfilled
})
})
})
describe('deleting docs', function () {
beforeEach(async function () {
const { projectId } = await createExampleProject(owner)
this.exampleProjectId = projectId
const folderId = await createExampleFolder(owner, projectId)
this.exampleFolderId = folderId
const docId = await createExampleDoc(owner, projectId)
this.exampleDocId = docId
MockDocUpdaterApi.reset()
const project = await ProjectGetter.promises.getProject(
this.exampleProjectId
)
this.project0 = project
})
it('should pass the doc name to docstore', async function () {
await deleteItem(owner, this.exampleProjectId, 'doc', this.exampleDocId)
expect(
MockDocStoreApi.getDeletedDocs(this.exampleProjectId)
).to.deep.equal([{ _id: this.exampleDocId, name: 'new.tex' }])
})
describe('when rootDoc_id matches doc being deleted', function () {
beforeEach(async function () {
await Project.updateOne(
{ _id: this.exampleProjectId },
{ $set: { rootDoc_id: this.exampleDocId } }
).exec()
})
it('should clear rootDoc_id', async function () {
await deleteItem(owner, this.exampleProjectId, 'doc', this.exampleDocId)
const project = ProjectGetter.promises.getProject(this.exampleProjectId)
expect(project.rootDoc_id).to.be.undefined
})
})
describe('when rootDoc_id does not match doc being deleted', function () {
beforeEach(async function () {
this.exampleRootDocId = new ObjectId()
await Project.updateOne(
{ _id: this.exampleProjectId },
{ $set: { rootDoc_id: this.exampleRootDocId } }
).exec()
})
it('should not clear rootDoc_id', async function () {
await deleteItem(owner, this.exampleProjectId, 'doc', this.exampleDocId)
const project = await ProjectGetter.promises.getProject(
this.exampleProjectId
)
expect(project.rootDoc_id.toString()).to.equal(
this.exampleRootDocId.toString()
)
})
})
})
})

View File

@@ -0,0 +1,92 @@
/* eslint-disable
max-len,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import request from './helpers/request.js'
const assertRedirect = (method, path, expectedStatusCode, destination, cb) =>
request[method](path, (error, response) => {
expect(error).not.to.exist
response.statusCode.should.equal(expectedStatusCode)
response.headers.location.should.equal(destination)
return cb()
})
describe('RedirectUrls', function () {
beforeEach(function () {
return this.timeout(1000)
})
it('proxy static URLs', function (done) {
return assertRedirect('get', '/redirect/one', 302, '/destination/one', done)
})
it('proxy dynamic URLs', function (done) {
return assertRedirect(
'get',
'/redirect/params/42',
302,
'/destination/42/params',
done
)
})
it('proxy URLs with baseUrl', function (done) {
return assertRedirect(
'get',
'/redirect/base_url',
302,
'https://example.com/destination/base_url',
done
)
})
it('proxy URLs with POST with a 307', function (done) {
return assertRedirect(
'post',
'/redirect/get_and_post',
307,
'/destination/get_and_post',
done
)
})
it('proxy URLs with multiple support methods', function (done) {
return assertRedirect(
'get',
'/redirect/get_and_post',
302,
'/destination/get_and_post',
done
)
})
it('redirects with query params', function (done) {
return assertRedirect(
'get',
'/redirect/qs?foo=bar&baz[]=qux1&baz[]=qux2',
302,
'/destination/qs?foo=bar&baz[]=qux1&baz[]=qux2',
done
)
})
it("skips redirects if the 'skip-redirects' header is set", function (done) {
return request.get(
{ url: '/redirect/one', headers: { 'x-skip-redirects': 'true' } },
(error, response) => {
expect(error).not.to.exist
response.statusCode.should.equal(404)
return done()
}
)
})
})

View File

@@ -0,0 +1,152 @@
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db } from '../../../app/src/infrastructure/mongodb.js'
import { renderObjectId } from '@overleaf/mongo-utils/batchedUpdate.js'
const BATCH_SIZE = 100
let n = 0
function getUniqueReferralId() {
return `unique_${n++}`
}
function getUserWithReferralId(referralId) {
const email = `${Math.random()}@example.com`
return {
referal_id: referralId,
// Make the unique indexes happy.
email,
emails: [{ email }],
}
}
async function getBatch(batchCounter) {
return (
await db.users
.find(
{},
{
projection: { _id: 1 },
skip: BATCH_SIZE * --batchCounter,
limit: BATCH_SIZE,
}
)
.toArray()
).map(user => user._id)
}
describe('RegenerateDuplicateReferralIds', function () {
let firstBatch, secondBatch, thirdBatch, forthBatch, duplicateAcrossBatch
beforeEach('insert duplicates', async function () {
// full batch of duplicates
await db.users.insertMany(
Array(BATCH_SIZE)
.fill(0)
.map(() => {
return getUserWithReferralId('duplicate1')
})
)
firstBatch = await getBatch(1)
// batch of 999 duplicates and 1 unique
await db.users.insertMany(
Array(BATCH_SIZE - 1)
.fill(0)
.map(() => {
return getUserWithReferralId('duplicate2')
})
.concat([getUserWithReferralId(getUniqueReferralId())])
)
secondBatch = await getBatch(2)
// duplicate outside batch
duplicateAcrossBatch = getUniqueReferralId()
await db.users.insertMany(
Array(BATCH_SIZE - 1)
.fill(0)
.map(() => {
return getUserWithReferralId(getUniqueReferralId())
})
.concat([getUserWithReferralId(duplicateAcrossBatch)])
)
thirdBatch = await getBatch(3)
// no new duplicates onwards
await db.users.insertMany(
Array(BATCH_SIZE - 1)
.fill(0)
.map(() => {
return getUserWithReferralId(getUniqueReferralId())
})
.concat([getUserWithReferralId(duplicateAcrossBatch)])
)
forthBatch = await getBatch(4)
})
let result
beforeEach('run script', async function () {
try {
result = await promisify(exec)(
[
// set low BATCH_SIZE
`BATCH_SIZE=${BATCH_SIZE}`,
// log details on duplicate matching
'VERBOSE_LOGGING=true',
// disable verbose logging
'LOG_LEVEL=ERROR',
// actual command
'node',
'scripts/regenerate_duplicate_referral_ids.mjs',
].join(' ')
)
} catch (err) {
// dump details like exit code, stdErr and stdOut
logger.error({ err }, 'script failed')
throw err
}
})
it('should do the correct operations', function () {
let { stderr: stdErr, stdout: stdOut } = result
stdErr = stdErr.split('\n').filter(filterOutput)
stdOut = stdOut.split('\n').filter(filterOutput)
expect(stdErr).to.include.members([
`Completed batch ending ${renderObjectId(firstBatch[BATCH_SIZE - 1])}`,
`Completed batch ending ${renderObjectId(secondBatch[BATCH_SIZE - 1])}`,
`Completed batch ending ${renderObjectId(thirdBatch[BATCH_SIZE - 1])}`,
`Completed batch ending ${renderObjectId(forthBatch[BATCH_SIZE - 1])}`,
'Done.',
])
expect(stdOut.filter(filterOutput)).to.include.members([
// only duplicates
`Running update on batch with ids ${JSON.stringify(firstBatch)}`,
'Got duplicates from looking at batch.',
'Found duplicate: duplicate1',
// duplicate in batch
`Running update on batch with ids ${JSON.stringify(secondBatch)}`,
'Got duplicates from looking at batch.',
'Found duplicate: duplicate2',
// duplicate with next batch
`Running update on batch with ids ${JSON.stringify(thirdBatch)}`,
'Got duplicates from running count.',
`Found duplicate: ${duplicateAcrossBatch}`,
// no new duplicates
`Running update on batch with ids ${JSON.stringify(forthBatch)}`,
])
})
it('should give all users a unique refereal_id', async function () {
const users = await db.users
.find({}, { projection: { referal_id: 1 } })
.toArray()
const uniqueReferralIds = Array.from(
new Set(users.map(user => user.referal_id))
)
expect(users).to.have.length(4 * BATCH_SIZE)
expect(uniqueReferralIds).to.have.length(users.length)
})
})

View File

@@ -0,0 +1,414 @@
import { expect } from 'chai'
import async from 'async'
import metrics from './helpers/metrics.mjs'
import User from './helpers/User.mjs'
import redis from './helpers/redis.mjs'
import Features from '../../../app/src/infrastructure/Features.js'
const UserPromises = User.promises
// Expectations
const expectProjectAccess = function (user, projectId, callback) {
// should have access to project
user.openProject(projectId, err => {
expect(err).to.be.oneOf([null, undefined])
return callback()
})
}
const expectNoProjectAccess = function (user, projectId, callback) {
// should not have access to project page
user.openProject(projectId, err => {
expect(err).to.be.instanceof(Error)
return callback()
})
}
// Actions
const tryLoginThroughRegistrationForm = function (
user,
email,
password,
callback
) {
user.getCsrfToken(err => {
if (err != null) {
return callback(err)
}
user.request.post(
{
url: '/register',
json: {
email,
password,
},
},
callback
)
})
}
describe('Registration', function () {
describe('LoginRateLimit', function () {
let userA
beforeEach(function () {
userA = new UserPromises()
})
function loginRateLimited(line) {
return line.includes('rate_limit_hit') && line.includes('login')
}
async function getLoginRateLimitHitMetricValue() {
return await metrics.promises.getMetric(loginRateLimited)
}
let beforeCount
beforeEach('get baseline metric value', async function () {
beforeCount = await getLoginRateLimitHitMetricValue()
})
beforeEach('setup csrf token', async function () {
await userA.getCsrfToken()
})
describe('pushing an account just below the rate limit', function () {
async function doLoginAttempts(user, n, pushInto) {
while (n--) {
const { body } = await user.doRequest('POST', {
url: '/login',
json: {
email: user.email,
password: 'invalid-password',
'g-recaptcha-response': 'valid',
},
})
const message = body && body.message && body.message.key
pushInto.push(message)
}
}
let results = []
beforeEach('do 9 login attempts', async function () {
results = []
await doLoginAttempts(userA, 9, results)
})
it('should not record any rate limited requests', async function () {
const afterCount = await getLoginRateLimitHitMetricValue()
expect(afterCount).to.equal(beforeCount)
})
it('should produce the correct responses so far', function () {
expect(results.length).to.equal(9)
expect(results).to.deep.equal(
Array(9).fill('invalid-password-retry-or-reset')
)
})
describe('pushing the account past the limit', function () {
beforeEach('do 6 login attempts', async function () {
await doLoginAttempts(userA, 6, results)
})
it('should record 5 rate limited requests', async function () {
const afterCount = await getLoginRateLimitHitMetricValue()
expect(afterCount).to.equal(beforeCount + 5)
})
it('should produce the correct responses', function () {
expect(results.length).to.equal(15)
expect(results).to.deep.equal(
Array(10)
.fill('invalid-password-retry-or-reset')
.concat(Array(5).fill('to-many-login-requests-2-mins'))
)
})
describe('logging in with another user', function () {
let userB
beforeEach(function () {
userB = new UserPromises()
})
beforeEach('update baseline metric value', async function () {
beforeCount = await getLoginRateLimitHitMetricValue()
})
beforeEach('setup csrf token', async function () {
await userB.getCsrfToken()
})
let messages = []
beforeEach('do bad login', async function () {
messages = []
await doLoginAttempts(userB, 1, messages)
})
it('should not rate limit their request', function () {
expect(messages).to.deep.equal(['invalid-password-retry-or-reset'])
})
it('should not record any further rate limited requests', async function () {
const afterCount = await getLoginRateLimitHitMetricValue()
expect(afterCount).to.equal(beforeCount)
})
})
})
describe('performing a valid login for clearing the limit', function () {
beforeEach('do login', async function () {
await userA.login()
})
it('should log the user in', async function () {
const { response } = await userA.doRequest('GET', '/project')
expect(response.statusCode).to.equal(200)
})
it('should not record any rate limited requests', async function () {
const afterCount = await getLoginRateLimitHitMetricValue()
expect(afterCount).to.equal(beforeCount)
})
describe('logging out and performing more invalid login requests', function () {
beforeEach('logout', async function () {
await userA.logout()
})
beforeEach('fetch new csrf token', async function () {
await userA.getCsrfToken()
})
let results = []
beforeEach('do 9 login attempts', async function () {
results = []
await doLoginAttempts(userA, 9, results)
})
it('should not record any rate limited requests yet', async function () {
const afterCount = await getLoginRateLimitHitMetricValue()
expect(afterCount).to.equal(beforeCount)
})
it('should not emit any rate limited responses yet', function () {
expect(results.length).to.equal(9)
expect(results).to.deep.equal(
Array(9).fill('invalid-password-retry-or-reset')
)
})
})
})
})
})
describe('CSRF protection', function () {
before(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
beforeEach(function () {
this.user = new User()
this.email = `test+${Math.random()}@example.com`
this.password = 'password11'
})
afterEach(function (done) {
this.user.fullDeleteUser(this.email, done)
})
it('should register with the csrf token', function (done) {
this.user.request.get('/login', (err, res, body) => {
expect(err).to.not.exist
this.user.getCsrfToken(error => {
expect(error).to.not.exist
this.user.request.post(
{
url: '/register',
json: {
email: this.email,
password: this.password,
},
headers: {
'x-csrf-token': this.user.csrfToken,
},
},
(error, response, body) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
return done()
}
)
})
})
})
it('should fail with no csrf token', function (done) {
this.user.request.get('/login', (err, res, body) => {
expect(err).to.not.exist
this.user.getCsrfToken(error => {
expect(error).to.not.exist
this.user.request.post(
{
url: '/register',
json: {
email: this.email,
password: this.password,
},
headers: {
'x-csrf-token': '',
},
},
(error, response, body) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(403)
return done()
}
)
})
})
})
it('should fail with a stale csrf token', function (done) {
this.user.request.get('/login', (err, res, body) => {
expect(err).to.not.exist
this.user.getCsrfToken(error => {
expect(error).to.not.exist
const oldCsrfToken = this.user.csrfToken
this.user.logout(err => {
expect(err).to.not.exist
this.user.request.post(
{
url: '/register',
json: {
email: this.email,
password: this.password,
},
headers: {
'x-csrf-token': oldCsrfToken,
},
},
(error, response, body) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(403)
return done()
}
)
})
})
})
})
})
describe('Register', function () {
before(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
beforeEach(function () {
this.user = new User()
})
it('Set emails attribute', function (done) {
this.user.register((error, user) => {
expect(error).to.not.exist
user.email.should.equal(this.user.email)
user.emails.should.exist
user.emails.should.be.a('array')
user.emails.length.should.equal(1)
user.emails[0].email.should.equal(this.user.email)
return done()
})
})
})
describe('LoginViaRegistration', function () {
beforeEach(function (done) {
this.timeout(60000)
this.user1 = new User()
this.user2 = new User()
async.series(
[
cb => this.user1.login(cb),
cb => this.user1.logout(cb),
cb => redis.clearUserSessions(this.user1, cb),
cb => this.user2.login(cb),
cb => this.user2.logout(cb),
cb => redis.clearUserSessions(this.user2, cb),
],
done
)
this.project_id = null
})
describe('[Security] Trying to register/login as another user', function () {
before(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
it('should not allow sign in with secondary email', function (done) {
const secondaryEmail = 'acceptance-test-secondary@example.com'
this.user1.addEmail(secondaryEmail, err => {
expect(err).to.not.exist
this.user1.loginWith(secondaryEmail, err => {
expect(err).to.match(/login failed: status=401/)
expect(err.info.body).to.deep.equal({
message: {
type: 'error',
key: 'invalid-password-retry-or-reset',
},
})
this.user1.isLoggedIn((err, isLoggedIn) => {
expect(err).to.not.exist
expect(isLoggedIn).to.equal(false)
return done()
})
})
})
})
it('should have user1 login and create a project, which user2 cannot access', function (done) {
let projectId
async.series(
[
// user1 logs in and creates a project which only they can access
cb => {
this.user1.login(err => {
expect(err).not.to.exist
cb()
})
},
cb => {
this.user1.createProject('Private Project', (err, id) => {
expect(err).not.to.exist
projectId = id
cb()
})
},
cb => expectProjectAccess(this.user1, projectId, cb),
cb => expectNoProjectAccess(this.user2, projectId, cb),
// should prevent user2 from login/register with user1 email address
cb => {
tryLoginThroughRegistrationForm(
this.user2,
this.user1.email,
'totally_not_the_right_password',
(err, response, body) => {
expect(err).to.not.exist
expect(body.redir != null).to.equal(false)
expect(body.message != null).to.equal(true)
expect(body.message).to.have.all.keys('type', 'text')
expect(body.message.type).to.equal('error')
cb()
}
)
},
// check user still can't access the project
cb => expectNoProjectAccess(this.user2, projectId, done),
],
done
)
})
})
})
})

View File

@@ -0,0 +1,256 @@
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import { promisify } from 'node:util'
import { exec } from 'node:child_process'
import logger from '@overleaf/logger'
import { expect } from 'chai'
describe('RemoveDeletedUsersFromTokenAccessRefsTests', function () {
const userId1 = new ObjectId()
const userId2 = new ObjectId()
const userId3 = new ObjectId()
let insertedUsersCount
beforeEach('insert users', async function () {
const users = await db.users.insertMany([
{ _id: userId1, email: 'user1@example.com' },
])
insertedUsersCount = users.insertedCount
})
const projectId1 = new ObjectId('65d726e807c024c8db43be22')
const projectId2 = new ObjectId('65d726e807c024c8db43be23')
const projectId3 = new ObjectId('65d726e807c024c8db43be24')
const projectId4 = new ObjectId('65d726e807c024c8db43be25')
let insertedProjects
beforeEach('insert projects', async function () {
insertedProjects = await db.projects.insertMany([
{
_id: projectId1,
tokenAccessReadAndWrite_refs: [userId1],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId2,
tokenAccessReadAndWrite_refs: [userId2],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId3,
tokenAccessReadAndWrite_refs: [userId3],
},
{
_id: projectId4,
},
])
})
let stdOut
const runScript = async (dryRun, projectsList) => {
let result
try {
result = await promisify(exec)(
[
'VERBOSE_LOGGING=true',
'node',
'scripts/remove_deleted_users_from_token_access_refs.mjs',
dryRun,
projectsList,
].join(' ')
)
} catch (error) {
// dump details like exit code, stdErr and stdOut
logger.error({ error }, 'script failed')
throw error
}
const { stdout } = result
stdOut = stdout
expect(stdOut).to.match(new RegExp(`User ids count: ${insertedUsersCount}`))
}
describe('dry-run=true', function () {
beforeEach('run script', async function () {
await runScript('--dry-run=true')
expect(stdOut).to.match(/doing dry run/i)
})
it('should show current user id to be removed', function () {
expect(stdOut).to.match(
new RegExp(
`Found deleted user id: ${userId2.toString()} in project: ${projectId2.toString()}`
)
)
expect(stdOut).to.match(
new RegExp(
`DRY RUN - would remove deleted ${userId2.toString()} from all projects \\(found in project ${projectId2.toString()}\\)`
)
)
expect(stdOut).to.match(
new RegExp(
`Found deleted user id: ${userId3.toString()} in project: ${projectId3.toString()}`
)
)
expect(stdOut).to.match(
new RegExp(
`DRY RUN - would remove deleted ${userId3.toString()} from all projects \\(found in project ${projectId3.toString()}\\)`
)
)
})
it('should show projects with non-existing token access fields', function () {
expect(stdOut)
.to.match(
new RegExp(
`DRY RUN - would fix non-existing token access fields in project ${projectId3.toString()}`
)
)
.and.match(
new RegExp(
`DRY RUN - would fix non-existing token access fields in project ${projectId4.toString()}`
)
)
})
it('should show the user ids (and their count) to be deleted', function () {
expect(stdOut).to.match(
new RegExp(
`DRY RUN - would delete user ids \\(2\\)\\n${userId2.toString()}\\n${userId3.toString()}`
)
)
})
it('should show the project ids (and their count) that needs fixing', function () {
expect(stdOut).to.match(
new RegExp(
`Projects with deleted user ids \\(2\\)\\n${projectId2.toString()}\\n${projectId3.toString()}`
)
)
})
it('should not fix the token access fields of projects', async function () {
const projects = await db.projects
.find({}, { $sort: { _id: 1 } })
.toArray()
expect(projects).to.deep.equal([
{
_id: projectId1,
tokenAccessReadAndWrite_refs: [userId1],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId2,
tokenAccessReadAndWrite_refs: [userId2],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId3,
tokenAccessReadAndWrite_refs: [userId3],
},
{
_id: projectId4,
},
])
})
})
describe('dry-run=false', function () {
beforeEach('run script', async function () {
await runScript('--dry-run=false')
expect(stdOut).to.not.match(/dry run/i)
})
it('should show current user id to be removed', function () {
expect(stdOut).to.match(
new RegExp(
`Found deleted user id: ${userId2.toString()} in project: ${projectId2.toString()}`
)
)
expect(stdOut).to.match(
new RegExp(
`Removing deleted ${userId2.toString()} from all projects \\(found in project ${projectId2.toString()}\\)`
)
)
expect(stdOut).to.match(
new RegExp(
`Found deleted user id: ${userId3.toString()} in project: ${projectId3.toString()}`
)
)
expect(stdOut).to.match(
new RegExp(
`Removing deleted ${userId3.toString()} from all projects \\(found in project ${projectId3.toString()}\\)`
)
)
})
it('should show fixed projects with non-existing token access fields', function () {
expect(stdOut)
.to.match(
new RegExp(
`Fixed non-existing token access fields in project ${projectId3.toString()}`
)
)
.and.match(
new RegExp(
`Fixed non-existing token access fields in project ${projectId4.toString()}`
)
)
})
it('should show the deleted user ids (and their count) that were removed', function () {
expect(stdOut).to.match(
new RegExp(
`Deleted user ids \\(2\\)\\n${userId2.toString()}\\n${userId3.toString()}`
)
)
})
it('should show the project ids (and their count) that were fixed', function () {
expect(stdOut).to.match(
new RegExp(
`Projects with deleted user ids \\(2\\)\\n${projectId2.toString()}\\n${projectId3.toString()}`
)
)
})
it('should fix the token access fields of projects', async function () {
const [, ...fixedProjects] = await db.projects
.find({}, { $sort: { _id: 1 } })
.toArray()
expect(fixedProjects).to.deep.equal([
{
_id: projectId2,
tokenAccessReadAndWrite_refs: [],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId3,
tokenAccessReadAndWrite_refs: [],
tokenAccessReadOnly_refs: [],
},
{
_id: projectId4,
tokenAccessReadOnly_refs: [],
tokenAccessReadAndWrite_refs: [],
},
])
})
})
describe('projects=projectId2', function () {
beforeEach('run script', async function () {
const projectId2 = insertedProjects.insertedIds[1]
await runScript('--dry-run=false', `--projects=${projectId2.toString()}`)
})
it('should fix only the projects provided', async function () {
const [project1, project2, project3] = await db.projects
.find({}, { $sort: { _id: 1 } })
.toArray()
expect(project1.tokenAccessReadAndWrite_refs.length).to.be.gt(0)
expect(project2.tokenAccessReadAndWrite_refs.length).to.eq(0) // deleted user removed
expect(project3.tokenAccessReadAndWrite_refs.length).to.be.gt(0)
})
})
})

View File

@@ -0,0 +1,290 @@
import { promisify } from 'node:util'
import { exec } from 'node:child_process'
import { expect } from 'chai'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import fs from 'node:fs/promises'
const CSV_FILENAME = '/tmp/remove_unconfirmed_emails.csv'
async function runScript(mode, commit) {
const result = await promisify(exec)(
[
'node',
'scripts/remove_unconfirmed_emails.mjs',
mode === 'generate' ? '--generate' : '--consume',
commit && '--commit',
]
.filter(Boolean)
.join(' ')
)
return {
...result,
stdout: result.stdout.split('\n').filter(filterOutput),
}
}
function createUser(signUpDate, emails, userIdx) {
const email = `primary${userIdx ?? ''}@overleaf.com`
return {
_id: new ObjectId(),
email,
emails,
signUpDate,
}
}
describe('scripts/remove_unconfirmed_emails', function () {
let user
afterEach(async function () {
try {
await fs.unlink(CSV_FILENAME)
} catch (err) {
// Ignore errors if file doesn't exist
}
})
describe('when removing unconfirmed secondary emails', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com', confirmedAt: new Date() },
{ email: 'unconfirmed1@overleaf.com' },
{ email: 'unconfirmed-special-,\'"@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should remove all unconfirmed secondary emails', async function () {
await runScript('generate')
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 2')
expect(r.stdout).to.include('Total users processed: 1')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(1)
expect(updatedUser.emails[0].email).to.equal(user.email)
})
it('should not modify anything in dry run mode', async function () {
await runScript('generate')
const r = await runScript('consume', false)
expect(r.stdout).to.include('Total emails in the CSV: 2')
expect(r.stdout).to.include('Total users processed: 1')
expect(r.stdout).to.include(
'Note: this was a dry-run. No changes were made.'
)
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(3)
})
})
describe('when handling confirmed secondary emails', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com', confirmedAt: new Date() },
{ email: 'confirmed@overleaf.com', confirmedAt: new Date() },
])
await db.users.insertOne(user)
})
it('should preserve confirmed secondary emails', async function () {
await runScript('generate')
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 0')
expect(r.stdout).to.include('Total users processed: 0')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(2)
expect(updatedUser.emails[1].confirmedAt).to.exist
})
})
describe('when handling unconfirmed primary emails', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should not remove unconfirmed primary emails', async function () {
await runScript('generate')
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 0')
expect(r.stdout).to.include('Total users processed: 0')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(1)
expect(updatedUser.emails[0].email).to.equal('primary@overleaf.com')
})
})
describe('when users confirmed their email in between', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com' },
{ email: 'secondary@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should not remove emails from users who confirmed their email in between', async function () {
await runScript('generate')
await db.users.updateOne(
{ _id: user._id },
{ $set: { 'emails.1.confirmedAt': new Date() } }
)
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 1')
expect(r.stdout).to.include('Skipped emails: 1')
expect(r.stdout).to.include(' - Email now confirmed: 1')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(2)
})
})
describe('when users changed their primary email in between', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com' },
{ email: 'secondary@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should not remove emails from users who changed their primary email in between', async function () {
await runScript('generate')
await db.users.updateOne(
{ _id: user._id },
{ $set: { email: 'secondary@overleaf.com' } }
)
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 1')
expect(r.stdout).to.include('Skipped emails: 1')
expect(r.stdout).to.include(' - Email now primary: 1')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(2)
})
})
describe('when users account was deleted in between', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com' },
{ email: 'secondary@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should skip emails from users whose account was deleted', async function () {
await runScript('generate')
// Delete the user
await db.users.deleteOne({ _id: user._id })
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 1')
expect(r.stdout).to.include('Skipped emails: 1')
expect(r.stdout).to.include(' - User not found: 1')
})
})
describe('when users email was deleted in between', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com' },
{ email: 'secondary@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should skip emails that were already removed', async function () {
await runScript('generate')
// Remove the secondary email
await db.users.updateOne(
{ _id: user._id },
{ $pull: { emails: { email: 'secondary@overleaf.com' } } }
)
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 1')
expect(r.stdout).to.include('Skipped emails: 1')
expect(r.stdout).to.include(' - Email now removed: 1')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(1)
expect(updatedUser.emails[0].email).to.equal('primary@overleaf.com')
})
})
describe('when handling confirmation field edge cases', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com', confirmedAt: new Date() },
{ email: 'secondary1@overleaf.com', confirmedAt: null },
{ email: 'secondary2@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should remove emails with both missing and null confirmedAt', async function () {
await runScript('generate')
const r = await runScript('consume', true)
expect(r.stdout).to.include('Total emails in the CSV: 2')
expect(r.stdout).to.include('Total users processed: 1')
const updatedUser = await db.users.findOne({ _id: user._id })
expect(updatedUser.emails).to.have.length(1)
expect(updatedUser.emails[0].email).to.equal(user.email)
})
})
describe('CSV file generation', function () {
beforeEach(async function () {
user = createUser(new Date('2000-01-01'), [
{ email: 'primary@overleaf.com', confirmedAt: new Date() },
{ email: 'unconfirmed1@overleaf.com' },
{ email: 'confirmed1@overleaf.com', confirmedAt: new Date() },
{ email: 'unconfirmed2@overleaf.com' },
{ email: '!,@overleaf.com' },
{ email: "!'@overleaf.com" },
{ email: '!,\'"@overleaf.com' },
])
await db.users.insertOne(user)
})
it('should generate a valid CSV file', async function () {
const r = await runScript('generate')
expect(r.stdout).to.include(
'Generated CSV file: /tmp/remove_unconfirmed_emails.csv'
)
expect(r.stdout).to.include('Total emails in the CSV: 5')
const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
expect(csvContent).to.equal(`User ID,Email,Sign Up Date
${user._id},unconfirmed1@overleaf.com,2000-01-01T00:00:00.000Z
${user._id},unconfirmed2@overleaf.com,2000-01-01T00:00:00.000Z
${user._id},"!,@overleaf.com",2000-01-01T00:00:00.000Z
${user._id},!'@overleaf.com,2000-01-01T00:00:00.000Z
${user._id},"!,'""@overleaf.com",2000-01-01T00:00:00.000Z
`)
})
})
})

View File

@@ -0,0 +1,203 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { assert } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
import request from './helpers/request.js'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
const assertHasCommonHeaders = function (response) {
const { headers } = response
assert.include(headers, {
'x-download-options': 'noopen',
'x-xss-protection': '0',
'cross-origin-resource-policy': 'same-origin',
'cross-origin-opener-policy': 'same-origin-allow-popups',
'x-content-type-options': 'nosniff',
'x-permitted-cross-domain-policies': 'none',
'referrer-policy': 'origin-when-cross-origin',
})
assert.isUndefined(headers['cross-origin-embedder-policy'])
}
const assertHasCacheHeaders = function (response) {
assert.include(response.headers, {
'surrogate-control': 'no-store',
'cache-control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
pragma: 'no-cache',
expires: '0',
})
}
const assertHasNoCacheHeaders = function (response) {
assert.doesNotHaveAnyKeys(response.headers, [
'surrogate-control',
'cache-control',
'pragma',
'expires',
])
}
const assertHasAssetCachingHeaders = function (response) {
assert.equal(response.headers['cache-control'], 'public, max-age=31536000')
}
describe('SecurityHeaders', function () {
beforeEach(function () {
return (this.user = new User())
})
it('should not have x-powered-by header', function (done) {
return request.get('/', (err, res, body) => {
assert.isUndefined(res.headers['x-powered-by'])
return done()
})
})
it('should have all common headers', function (done) {
return request.get('/', (err, res, body) => {
assertHasCommonHeaders(res)
return done()
})
})
it('should not have cache headers on public pages', function (done) {
return request.get('/', (err, res, body) => {
assertHasNoCacheHeaders(res)
return done()
})
})
it('should have caching headers on static assets', function (done) {
request.get('/favicon.ico', (err, res) => {
assertHasAssetCachingHeaders(res)
done(err)
})
})
it('should have cache headers when user is logged in', function (done) {
return async.series(
[
cb => this.user.login(cb),
cb => this.user.request.get('/', cb),
cb => this.user.logout(cb),
],
(err, results) => {
const mainResponse = results[1][0]
assertHasCacheHeaders(mainResponse)
return done()
}
)
})
it('should have cache headers on project page when user is logged out', function (done) {
return async.series(
[
cb => this.user.login(cb),
cb =>
this.user.createProject('public-project', (error, projectId) => {
if (error != null) {
return done(error)
}
this.project_id = projectId
return this.user.makePublic(this.project_id, 'readAndWrite', cb)
}),
cb => this.user.logout(cb),
cb => request.get(`/project/${this.project_id}`, cb),
],
(err, res) => {
const mainResponse = res[3][0]
assertHasCacheHeaders(mainResponse)
return done()
}
)
})
it('should have private cache headers on project file', function (done) {
return async.series(
[
cb => this.user.login(cb),
cb =>
this.user.createProject(
'public-project',
(error, projectId, folderId) => {
if (error != null) {
return done(error)
}
this.project_id = projectId
return this.user.makePublic(this.project_id, 'readAndWrite', cb)
}
),
cb =>
ProjectGetter.getProject(this.project_id, (error, project) => {
if (error) {
return cb(error)
}
this.root_folder_id = project.rootFolder[0]._id.toString()
cb()
}),
cb => {
return this.user.uploadFileInProject(
this.project_id,
this.root_folder_id,
'2pixel.png',
'1pixel.png',
'image/png',
(error, fileId) => {
if (error) {
return cb(error)
}
this.file_id = fileId
cb()
}
)
},
cb =>
request.get(`/project/${this.project_id}/file/${this.file_id}`, cb),
cb => this.user.logout(cb),
],
(err, results) => {
const res = results[4][0]
assert.include(res.headers, {
'cache-control': 'private, max-age=3600',
})
assert.doesNotHaveAnyKeys(res.headers, [
'surrogate-control',
'pragma',
'expires',
])
return done()
}
)
})
it('should have caching headers on static assets when user is logged in', function (done) {
async.series(
[
cb => this.user.login(cb),
cb => this.user.request.get('/favicon.ico', cb),
cb => this.user.logout(cb),
],
(err, results) => {
const res = results[1][0]
assertHasAssetCachingHeaders(res)
done()
}
)
})
})

View File

@@ -0,0 +1,35 @@
import { expect } from 'chai'
import fs from 'node:fs'
import Path from 'node:path'
import fetch from 'node-fetch'
import UserHelper from './helpers/UserHelper.mjs'
import glob from 'glob'
import { fileURLToPath } from 'node:url'
const BASE_URL = UserHelper.baseUrl()
const __dirname = fileURLToPath(new URL('.', import.meta.url))
// Test all files in the crash_test_urls directory
const CRASH_TEST_FILES = glob.sync(
Path.join(__dirname, '../files/crash_test_urls/*.txt')
)
describe('Server Crash Tests', function () {
for (const file of CRASH_TEST_FILES) {
const crashTestUrls = fs.readFileSync(file).toString().split('\n')
it(`should not crash on bad urls in ${file}`, async function () {
// increase the timeout for these tests due to the number of urls
this.timeout(60 * 1000)
// test each url in the list
for (let i = 0; i < crashTestUrls.length; i++) {
const url = BASE_URL + crashTestUrls[i]
const response = await fetch(url)
expect(response.status).to.not.match(
/5\d\d/,
`Request to ${url} failed with status ${response.status}`
)
}
})
}
})

View File

@@ -0,0 +1,559 @@
import { expect } from 'chai'
import async from 'async'
import UserHelper from './helpers/User.mjs'
import redis from './helpers/redis.mjs'
import UserSessionsRedis from '../../../app/src/Features/User/UserSessionsRedis.js'
const rclient = UserSessionsRedis.client()
describe('Sessions', function () {
beforeEach(function (done) {
this.timeout(20000)
this.user1 = new UserHelper()
this.site_admin = new UserHelper({ email: 'admin@example.com' })
async.series(
[cb => this.user1.login(cb), cb => this.user1.logout(cb)],
done
)
})
describe('one session', function () {
it('should have one session in UserSessions set', function (done) {
async.series(
[
next => {
redis.clearUserSessions(this.user1, next)
},
// login, should add session to set
next => {
this.user1.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
next()
})
},
// should be able to access project list page
next => {
this.user1.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
// logout, should remove session from set
next => {
this.user1.logout(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(0)
next()
})
},
],
(err, result) => {
if (err) {
throw err
}
done()
}
)
})
})
describe('two sessions', function () {
beforeEach(function () {
// set up second session for this user
this.user2 = new UserHelper()
this.user2.email = this.user1.email
this.user2.emails = this.user1.emails
this.user2.password = this.user1.password
})
it('should have two sessions in UserSessions set', function (done) {
async.series(
[
next => {
redis.clearUserSessions(this.user1, next)
},
// login, should add session to set
next => {
this.user1.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
next()
})
},
// login again, should add the second session to set
next => {
this.user2.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(2)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
expect(sessions[1].slice(0, 5)).to.equal('sess:')
next()
})
},
// both should be able to access project list page
next => {
this.user1.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
next => {
this.user2.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
// logout first session, should remove session from set
next => {
this.user1.logout(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
next()
})
},
// first session should not have access to project list page
next => {
this.user1.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
// second session should still have access to settings
next => {
this.user2.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
// logout second session, should remove last session from set
next => {
this.user2.logout(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(0)
next()
})
},
// second session should not have access to project list page
next => {
this.user2.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
],
(err, result) => {
if (err) {
throw err
}
done()
}
)
})
})
describe('three sessions, password reset', function () {
beforeEach(function () {
// set up second session for this user
this.user2 = new UserHelper()
this.user2.email = this.user1.email
this.user2.emails = this.user1.emails
this.user2.password = this.user1.password
this.user3 = new UserHelper()
this.user3.email = this.user1.email
this.user3.emails = this.user1.emails
this.user3.password = this.user1.password
})
it('should erase both sessions when password is reset', function (done) {
async.series(
[
next => {
redis.clearUserSessions(this.user1, next)
},
// login, should add session to set
next => {
this.user1.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
next()
})
},
// login again, should add the second session to set
next => {
this.user2.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(2)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
expect(sessions[1].slice(0, 5)).to.equal('sess:')
next()
})
},
// login third session, should add the second session to set
next => {
this.user3.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(3)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
expect(sessions[1].slice(0, 5)).to.equal('sess:')
next()
})
},
// password reset from second session, should erase two of the three sessions
next => {
this.user2.changePassword(`password${Date.now()}`, err => next(err))
},
next => {
redis.getUserSessions(this.user2, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
next()
})
},
// users one and three should not be able to access project list page
next => {
this.user1.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
next => {
this.user3.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
// user two should still be logged in, and able to access project list page
next => {
this.user2.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
// logout second session, should remove last session from set
next => {
this.user2.logout(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(0)
next()
})
},
],
(err, result) => {
if (err) {
throw err
}
done()
}
)
})
})
describe('three sessions, sessions page', function () {
beforeEach(function (done) {
// set up second session for this user
this.user2 = new UserHelper()
this.user2.email = this.user1.email
this.user2.emails = this.user1.emails
this.user2.password = this.user1.password
this.user3 = new UserHelper()
this.user3.email = this.user1.email
this.user3.emails = this.user1.emails
this.user3.password = this.user1.password
async.series([this.user2.login.bind(this.user2)], done)
})
it('should allow the user to erase the other two sessions', function (done) {
async.series(
[
next => {
redis.clearUserSessions(this.user1, next)
},
// login, should add session to set
next => {
this.user1.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
next()
})
},
// login again, should add the second session to set
next => {
this.user2.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(2)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
expect(sessions[1].slice(0, 5)).to.equal('sess:')
next()
})
},
// login third session, should add the second session to set
next => {
this.user3.login(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(3)
expect(sessions[0].slice(0, 5)).to.equal('sess:')
expect(sessions[1].slice(0, 5)).to.equal('sess:')
next()
})
},
// check the sessions page
next => {
this.user2.request.get(
{
uri: '/user/sessions',
},
(err, response, body) => {
expect(err).to.be.oneOf([null, undefined])
expect(response.statusCode).to.equal(200)
next()
}
)
},
// clear sessions from second session, should erase two of the three sessions
next => {
this.user2.getCsrfToken(err => {
expect(err).to.be.oneOf([null, undefined])
this.user2.request.post(
{
uri: '/user/sessions/clear',
},
err => next(err)
)
})
},
next => {
redis.getUserSessions(this.user2, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(1)
next()
})
},
// users one and three should not be able to access project list page
next => {
this.user1.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
next => {
this.user3.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(302)
next()
})
},
// user two should still be logged in, and able to access project list page
next => {
this.user2.getProjectListPage((err, statusCode) => {
expect(err).to.equal(null)
expect(statusCode).to.equal(200)
next()
})
},
// logout second session, should remove last session from set
next => {
this.user2.logout(err => next(err))
},
next => {
redis.getUserSessions(this.user1, (err, sessions) => {
expect(err).to.not.exist
expect(sessions.length).to.equal(0)
next()
})
},
// the user audit log should have been updated
next => {
this.user1.getAuditLogWithoutNoise((error, auditLog) => {
expect(error).not.to.exist
expect(auditLog).to.exist
expect(auditLog[0].operation).to.equal('clear-sessions')
expect(auditLog[0].ipAddress).to.exist
expect(auditLog[0].initiatorId).to.exist
expect(auditLog[0].timestamp).to.exist
expect(auditLog[0].info.sessions.length).to.equal(2)
next()
})
},
],
(err, result) => {
if (err) {
throw err
}
done()
}
)
})
})
describe('validationToken', function () {
const User = UserHelper.promises
async function tryWithValidationToken(validationToken) {
const user = new User()
await user.login()
await checkSessionIsValid(user)
const [, sid] = user.sessionCookie().value.match(/^s:(.+?)\./)
const key = `sess:${sid}`
const sess = JSON.parse(await rclient.get(key))
expect(sess.validationToken).to.equal('v1:' + sid.slice(-4))
sess.validationToken = validationToken
await rclient.set(key, JSON.stringify(sess))
{
// The current code destroys the session and throws an error/500.
// Check for login redirect on page reload.
await user.doRequest('GET', '/project')
const { response } = await user.doRequest('GET', '/project')
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal('/login?')
}
}
async function getOtherUsersValidationToken() {
const otherUser = new User()
await otherUser.login()
await checkSessionIsValid(otherUser)
const { validationToken } = await otherUser.getSession()
expect(validationToken).to.match(/^v1:.{4}$/)
return validationToken
}
async function checkSessionIsValid(user) {
const { response } = await user.doRequest('GET', '/project')
expect(response.statusCode).to.equal(200)
}
it('should reject the redis value when missing', async function () {
await tryWithValidationToken(undefined)
})
it('should reject the redis value when empty', async function () {
await tryWithValidationToken('')
})
it('should reject the redis value when out of sync', async function () {
await tryWithValidationToken(await getOtherUsersValidationToken())
})
it('should ignore overwrites in app code', async function () {
const otherUsersValidationToken = await getOtherUsersValidationToken()
const user = new User()
await user.login()
await checkSessionIsValid(user)
const { validationToken: token1 } = await user.getSession()
const allowedUpdateValue = 'allowed-update-value'
await user.setInSession({
validationToken: otherUsersValidationToken,
// also update another field to check that the write operation went through
allowedUpdate: allowedUpdateValue,
})
const { validationToken: token2, allowedUpdate } = await user.getSession()
expect(allowedUpdate).to.equal(allowedUpdateValue)
expect(token1).to.equal(token2)
await checkSessionIsValid(user)
})
})
})

View File

@@ -0,0 +1,61 @@
/* eslint-disable
n/handle-callback-err,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
import Features from '../../../app/src/infrastructure/Features.js'
describe('SettingsPage', function () {
beforeEach(function (done) {
this.user = new User()
return async.series(
[
this.user.ensureUserExists.bind(this.user),
this.user.login.bind(this.user),
],
done
)
})
it('load settings page', function (done) {
return this.user.getUserSettingsPage((err, statusCode) => {
statusCode.should.equal(200)
return done()
})
})
it('update main email address', function (done) {
if (Features.externalAuthenticationSystemUsed()) {
this.skip()
return
}
const newEmail = 'foo@bar.com'
return this.user.updateSettings({ email: newEmail }, error => {
expect(error).not.to.exist
return this.user.get((error, user) => {
user.email.should.equal(newEmail)
user.emails.length.should.equal(1)
user.emails[0].email.should.equal(newEmail)
return done()
})
})
})
it('prevents first name from being updated to a string longer than 255 characters', function (done) {
const newFirstName = 'a'.repeat(256)
return this.user.updateSettings({ first_name: newFirstName }, error => {
expect(error).to.exist
expect(error.message).to.contain('update settings failed: status=400')
return done()
})
})
})

View File

@@ -0,0 +1,152 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('Sharing', function () {
beforeEach(async function () {
this.ownerSession = new User()
this.collaboratorSession = new User()
this.strangerSession = new User()
this.reviewerSession = new User()
await this.ownerSession.login()
await this.collaboratorSession.login()
await this.strangerSession.login()
await this.reviewerSession.login()
this.owner = await this.ownerSession.get()
this.collaborator = await this.collaboratorSession.get()
this.stranger = await this.strangerSession.get()
this.reviewer = await this.reviewerSession.get()
this.projectId = await this.ownerSession.createProject('Test project')
})
describe('with read-only collaborator', function () {
beforeEach(async function () {
await this.ownerSession.addUserToProject(
this.projectId,
this.collaborator,
'readOnly'
)
})
it('sets the privilege level to read-write', async function () {
await this.ownerSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'readAndWrite' }
)
const project = await this.ownerSession.getProject(this.projectId)
expect(project.collaberator_refs).to.deep.equal([this.collaborator._id])
expect(project.readOnly_refs).to.deep.equal([])
expect(project.reviewer_refs).to.deep.equal([])
})
it('sets the privilege level to review', async function () {
await this.ownerSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'review' }
)
const project = await this.ownerSession.getProject(this.projectId)
expect(project.reviewer_refs).to.deep.equal([this.collaborator._id])
expect(project.collaberator_refs).to.deep.equal([])
expect(project.readOnly_refs).to.deep.equal([])
})
it('treats setting the privilege to read-only as a noop', async function () {
await this.ownerSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'readOnly' }
)
const project = await this.ownerSession.getProject(this.projectId)
expect(project.collaberator_refs).to.deep.equal([])
expect(project.reviewer_refs).to.deep.equal([])
expect(project.readOnly_refs).to.deep.equal([this.collaborator._id])
})
it('prevents non-owners to set the privilege level', async function () {
await expect(
this.collaboratorSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'readAndWrite' }
)
).to.be.rejectedWith(/failed: status=403 /)
})
it('validates the privilege level', async function () {
await expect(
this.collaboratorSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'superpowers' }
)
).to.be.rejectedWith(/failed: status=400 /)
})
it('returns 404 if the user is not already a collaborator', async function () {
await expect(
this.ownerSession.setCollaboratorInfo(
this.projectId,
this.stranger._id,
{ privilegeLevel: 'readOnly' }
)
).to.be.rejectedWith(/failed: status=404 /)
})
})
describe('with read-write collaborator', function () {
beforeEach(async function () {
await this.ownerSession.addUserToProject(
this.projectId,
this.collaborator,
'readAndWrite'
)
})
it('sets the privilege level to read-only', async function () {
await this.ownerSession.setCollaboratorInfo(
this.projectId,
this.collaborator._id,
{ privilegeLevel: 'readOnly' }
)
const project = await this.ownerSession.getProject(this.projectId)
expect(project.collaberator_refs).to.deep.equal([])
expect(project.reviewer_refs).to.deep.equal([])
expect(project.readOnly_refs).to.deep.equal([this.collaborator._id])
})
})
describe('with reviewer collaborator', function () {
beforeEach(async function () {
await this.ownerSession.addUserToProject(
this.projectId,
this.reviewer,
'review'
)
})
it('prevents non-owners to set the privilege level', async function () {
await expect(
this.collaboratorSession.setCollaboratorInfo(
this.projectId,
this.reviewer._id,
{ privilegeLevel: 'review' }
)
).to.be.rejectedWith(/failed: status=403 /)
})
it('sets the privilege level to read-only', async function () {
await this.ownerSession.setCollaboratorInfo(
this.projectId,
this.reviewer._id,
{ privilegeLevel: 'readOnly' }
)
const project = await this.ownerSession.getProject(this.projectId)
expect(project.collaberator_refs).to.deep.equal([])
expect(project.reviewer_refs).to.deep.equal([])
expect(project.readOnly_refs).to.deep.equal([this.reviewer._id])
})
})
})

View File

@@ -0,0 +1,388 @@
import User from './helpers/User.mjs'
import async from 'async'
import { expect } from 'chai'
import _ from 'lodash'
import request from './helpers/request.js'
import expectErrorResponse from './helpers/expectErrorResponse.mjs'
const _initUser = (user, callback) => {
async.series([cb => user.login(cb), cb => user.getCsrfToken(cb)], callback)
}
const _initUsers = (users, callback) => {
async.each(users, _initUser, callback)
}
const _expect200 = (err, response) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(200)
}
const _expect204 = (err, response) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(204)
}
const _createTag = (user, name, callback) => {
user.request.post({ url: `/tag`, json: { name } }, callback)
}
const _createTags = (user, tagNames, callback) => {
const tags = []
async.series(
tagNames.map(
tagName => cb =>
_createTag(user, tagName, (err, response, body) => {
_expect200(err, response)
tags.push(body)
cb()
})
),
err => {
callback(err, tags)
}
)
}
const _getTags = (user, callback) => {
user.request.get({ url: `/tag`, json: true }, callback)
}
const _names = tags => {
return tags.map(tag => tag.name)
}
const _ids = tags => {
return tags.map(tag => tag._id)
}
const _expectTagStructure = tag => {
expect(tag).to.have.keys('_id', 'user_id', 'name', 'project_ids', '__v')
expect(typeof tag._id).to.equal('string')
expect(typeof tag.user_id).to.equal('string')
expect(typeof tag.name).to.equal('string')
expect(tag.project_ids).to.deep.equal([])
}
describe('Tags', function () {
beforeEach(function (done) {
this.user = new User()
this.otherUser = new User()
_initUsers([this.user, this.otherUser], done)
})
describe('get tags, anonymous', function () {
it('should refuse to get user tags', function (done) {
this.user.logout(err => {
if (err) {
return done(err)
}
_getTags(this.user, (err, response, body) => {
expect(err).to.not.exist
expectErrorResponse.requireLogin.json(response, body)
done()
})
})
})
})
describe('get tags, none', function () {
it('should get user tags', function (done) {
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
expect(body).to.deep.equal([])
done()
})
})
})
describe('create some tags, then get', function () {
it('should get tags only for that user', function (done) {
// Create a few tags
_createTags(this.user, ['one', 'two', 'three'], (err, tags) => {
expect(err).to.not.exist
// Check structure of tags we just created
expect(tags.length).to.equal(3)
for (const tag of tags) {
_expectTagStructure(tag)
expect(tag.user_id).to.equal(this.user._id.toString())
}
// Get the list of tags for this user
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
expect(body).to.be.an.instanceof(Array)
expect(body.length).to.equal(3)
// Check structure of each tag in response
for (const tag of body) {
_expectTagStructure(tag)
expect(tag.user_id).to.equal(this.user._id.toString())
}
// Check that the set of ids we created are the same as
// the ids we got in the tag-list body
expect(_.sortBy(_ids(tags))).to.deep.equal(_.sortBy(_ids(body)))
// Check that the other user can't see these tags
_getTags(this.otherUser, (err, response, body) => {
_expect200(err, response)
expect(body).to.deep.equal([])
done()
})
})
})
})
})
describe('get tags via api', function () {
const auth = Buffer.from('overleaf:password').toString('base64')
const authedRequest = request.defaults({
headers: {
Authorization: `Basic ${auth}`,
},
})
it('should disallow without appropriate auth headers', function (done) {
_createTags(this.user, ['one', 'two', 'three'], (err, tags) => {
expect(err).to.not.exist
// Get the tags, but with a regular request, not authorized
request.get(
{ url: `/user/${this.user._id}/tag`, json: true },
(err, response, body) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(401)
expect(body).to.equal('Unauthorized')
done()
}
)
})
})
it('should get the tags from api endpoint', function (done) {
_createTags(this.user, ['one', 'two', 'three'], (err, tags) => {
expect(err).to.not.exist
// Get tags for user
authedRequest.get(
{ url: `/user/${this.user._id}/tag`, json: true },
(err, response, body) => {
_expect200(err, response)
expect(body.length).to.equal(3)
// Get tags for other user, expect none
authedRequest.get(
{ url: `/user/${this.otherUser._id}/tag`, json: true },
(err, response, body) => {
_expect200(err, response)
expect(body.length).to.equal(0)
done()
}
)
}
)
})
})
})
describe('rename tag', function () {
it('should reject malformed tag id', function (done) {
this.user.request.post(
{ url: `/tag/lol/rename`, json: { name: 'five' } },
(err, response) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(500)
done()
}
)
})
it('should allow user to rename a tag', function (done) {
_createTags(this.user, ['one', 'two'], (err, tags) => {
expect(err).to.not.exist
// Pick out the first tag
const firstTagId = tags[0]._id
// Change its name
this.user.request.post(
{ url: `/tag/${firstTagId}/rename`, json: { name: 'five' } },
(err, response) => {
_expect204(err, response)
// Get the tag list
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
expect(body.length).to.equal(2)
// Check the set of tag names is correct
const tagNames = _names(body)
expect(_.sortBy(tagNames)).to.deep.equal(
_.sortBy(['five', 'two'])
)
// Check the id is the same
const tagWithNameFive = _.find(body, t => t.name === 'five')
expect(tagWithNameFive._id).to.equal(firstTagId)
done()
})
}
)
})
})
it('should not allow other user to change name', function (done) {
const initialTagNames = ['one', 'two']
_createTags(this.user, initialTagNames, (err, tags) => {
expect(err).to.not.exist
const firstTagId = tags[0]._id
// Post with the other user
this.otherUser.request.post(
{ url: `/tag/${firstTagId}/rename`, json: { name: 'six' } },
(err, response) => {
_expect204(err, response)
// Should not have altered the tag
this.user.request.get(
{ url: `/tag`, json: true },
(err, response, body) => {
_expect200(err, response)
expect(_.sortBy(_names(body))).to.deep.equal(
_.sortBy(initialTagNames)
)
done()
}
)
}
)
})
})
})
describe('delete tag', function () {
it('should reject malformed tag id', function (done) {
this.user.request.delete(
{ url: `/tag/lol`, json: { name: 'five' } },
(err, response) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(500)
done()
}
)
})
it('should delete a tag', function (done) {
const initialTagNames = ['one', 'two', 'three']
_createTags(this.user, initialTagNames, (err, tags) => {
expect(err).to.not.exist
const firstTagId = tags[0]._id
this.user.request.delete(
{ url: `/tag/${firstTagId}` },
(err, response) => {
_expect204(err, response)
// Check the tag list
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
expect(_.sortBy(_names(body))).to.deep.equal(
_.sortBy(['two', 'three'])
)
done()
})
}
)
})
})
})
describe('add project to tag', function () {
beforeEach(function (done) {
this.user.createProject('test 1', (err, projectId) => {
if (err) {
return done(err)
}
this.projectId = projectId
done()
})
})
it('should reject malformed tag id', function (done) {
this.user.request.post(
{ url: `/tag/lol/project/bad` },
(err, response) => {
expect(err).to.not.exist
expect(response.statusCode).to.equal(500)
done()
}
)
})
it('should allow the user to add a project to a tag, and remove it', function (done) {
_createTags(this.user, ['one', 'two'], (err, tags) => {
expect(err).to.not.exist
const firstTagId = tags[0]._id
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
// Confirm that project_ids is empty for this tag
expect(
_.find(body, tag => tag.name === 'one').project_ids
).to.deep.equal([])
// Add the project to the tag
this.user.request.post(
{ url: `/tag/${firstTagId}/project/${this.projectId}` },
(err, response) => {
_expect204(err, response)
// Get tags again
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
// Check the project has been added to project_ids
expect(
_.find(body, tag => tag.name === 'one').project_ids
).to.deep.equal([this.projectId])
// Remove the project from the tag
this.user.request.delete(
{ url: `/tag/${firstTagId}/project/${this.projectId}` },
(err, response) => {
_expect204(err, response)
// Check tag list again
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
// Check the project has been removed from project_ids
expect(
_.find(body, tag => tag.name === 'one').project_ids
).to.deep.equal([])
done()
})
}
)
})
}
)
})
})
})
it('should not allow another user to add a project to the tag', function (done) {
_createTags(this.user, ['one', 'two'], (err, tags) => {
expect(err).to.not.exist
const firstTagId = tags[0]._id
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
// Confirm that project_ids is empty for this tag
expect(
_.find(body, tag => tag.name === 'one').project_ids
).to.deep.equal([])
// Have the other user try to add their own project to the tag
this.otherUser.createProject(
'rogue project',
(err, rogueProjectId) => {
expect(err).to.not.exist
this.otherUser.request.post(
{ url: `/tag/${firstTagId}/project/${rogueProjectId}` },
(err, response) => {
_expect204(err, response)
// Get original user tags again
_getTags(this.user, (err, response, body) => {
_expect200(err, response)
// Check the rogue project has not been added to project_ids
expect(
_.find(body, tag => tag.name === 'one').project_ids
).to.deep.equal([])
done()
})
}
)
}
)
})
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import { expect } from 'chai'
import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
import request from './helpers/request.js'
import User from './helpers/User.mjs'
describe('TpdsUpdateTests', function () {
beforeEach(function (done) {
this.owner = new User()
this.owner.login(error => {
if (error) {
throw error
}
this.owner.createProject(
'test-project',
{ template: 'example' },
(error, projectId) => {
if (error) {
throw error
}
this.projectId = projectId
done()
}
)
})
})
describe('adding a file', function () {
beforeEach(function (done) {
request(
{
method: 'POST',
url: `/project/${this.projectId}/contents/test.tex`,
auth: {
username: 'overleaf',
password: 'password',
sendImmediately: true,
},
body: 'test one two',
},
(error, response, body) => {
if (error) {
throw error
}
expect(response.statusCode).to.equal(200)
done()
}
)
})
it('should have added the file', function (done) {
ProjectGetter.getProject(this.projectId, (error, project) => {
if (error) {
throw error
}
const projectFolder = project.rootFolder[0]
const file = projectFolder.docs.find(e => e.name === 'test.tex')
expect(file).to.exist
done()
})
})
})
describe('deleting a file', function () {
beforeEach(function (done) {
request(
{
method: 'DELETE',
url: `/project/${this.projectId}/contents/main.tex`,
auth: {
username: 'overleaf',
password: 'password',
sendImmediately: true,
},
},
(error, response, body) => {
if (error) {
throw error
}
expect(response.statusCode).to.equal(200)
done()
}
)
})
it('should have deleted the file', function (done) {
ProjectGetter.getProject(this.projectId, (error, project) => {
if (error) {
throw error
}
const projectFolder = project.rootFolder[0]
for (const doc of projectFolder.docs) {
if (doc.name === 'main.tex') {
throw new Error('expected main.tex to have been deleted')
}
}
done()
})
})
})
describe('update a new file', function () {
beforeEach(function (done) {
request(
{
method: 'POST',
url: `/user/${this.owner._id}/update/test-project/other.tex`,
auth: {
username: 'overleaf',
password: 'password',
sendImmediately: true,
},
body: 'test one two',
},
(error, response, body) => {
if (error) {
throw error
}
expect(response.statusCode).to.equal(200)
const json = JSON.parse(response.body)
expect(json.status).to.equal('applied')
expect(json.entityType).to.equal('doc')
expect(json).to.have.property('entityId')
expect(json).to.have.property('rev')
done()
}
)
})
it('should have added the file', function (done) {
ProjectGetter.getProject(this.projectId, (error, project) => {
if (error) {
throw error
}
const projectFolder = project.rootFolder[0]
const file = projectFolder.docs.find(e => e.name === 'other.tex')
expect(file).to.exist
done()
})
})
})
describe('update when the project is archived', function () {
beforeEach(function (done) {
this.owner.request(
{
url: `/Project/${this.projectId}/archive`,
method: 'post',
},
(err, response, body) => {
expect(err).to.not.exist
request(
{
method: 'POST',
url: `/user/${this.owner._id}/update/test-project/test.tex`,
auth: {
username: 'overleaf',
password: 'password',
sendImmediately: true,
},
body: 'test one two',
},
(error, response, body) => {
if (error) {
throw error
}
expect(response.statusCode).to.equal(200)
const json = JSON.parse(response.body)
expect(json.status).to.equal('rejected')
done()
}
)
}
)
})
it('should not have created a new project', function (done) {
ProjectGetter.findAllUsersProjects(
this.owner._id,
'name',
(err, projects) => {
expect(err).to.not.exist
expect(projects.owned.length).to.equal(1)
done()
}
)
})
it('should not have added the file', function (done) {
ProjectGetter.getProject(this.projectId, (error, project) => {
if (error) {
throw error
}
const projectFolder = project.rootFolder[0]
const file = projectFolder.docs.find(e => e.name === 'test.tex')
expect(file).to.not.exist
done()
})
})
})
})

View File

@@ -0,0 +1,201 @@
import { expect } from 'chai'
import User from './helpers/User.mjs'
const botUserAgents = new Map([
[
'Googlebot',
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
],
])
const unsupportedUserAgents = new Map([
['IE 11', 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko'],
[
'Safari 13',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_12_2) AppleWebKit/629.24.7 (KHTML, like Gecko) Version/13.0.26 Safari/629.24.7',
],
[
'Safari 14',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_5_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15',
],
[
'Firefox 78',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11.1; rv:78.0) Gecko/20100101 Firefox/78.0',
],
])
const supportedUserAgents = new Map([
[
'Chrome 90',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36',
],
[
'Chrome 121',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
],
[
'Firefox 79',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:79.0) Gecko/20100101 Firefox/79.0',
],
[
'Firefox 122',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0',
],
[
'Safari 15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15',
],
[
'Safari 17',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15',
],
])
describe('UnsupportedBrowsers', function () {
beforeEach(function () {
this.user = new User()
})
describe('allows bots', function () {
const url = '/login'
for (const [name, userAgent] of botUserAgents) {
it(name, function (done) {
this.user.request(
{
url,
headers: {
'user-agent': userAgent,
},
},
(error, response) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
done()
}
)
})
}
})
describe('allows supported browsers', function () {
const url = '/login'
for (const [name, userAgent] of supportedUserAgents) {
it(name, function (done) {
this.user.request(
{
url,
headers: {
'user-agent': userAgent,
},
},
(error, response) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
done()
}
)
})
}
})
describe('redirects unsupported browsers to unsupported page', function () {
const url = '/login'
for (const [name, userAgent] of unsupportedUserAgents) {
it(name, function (done) {
this.user.request(
{
url,
headers: {
'user-agent': userAgent,
},
},
(error, response) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(
'/unsupported-browser?fromURL=' + encodeURIComponent(url)
)
done()
}
)
})
}
})
it('redirects unsupported browsers from any page', function (done) {
const url = '/foo/bar/baz'
this.user.request(
{
url,
headers: {
'user-agent': unsupportedUserAgents.get('IE 11'),
},
},
(error, response) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.equal(
'/unsupported-browser?fromURL=' + encodeURIComponent(url)
)
done()
}
)
})
it('should render the unsupported browser page for unsupported browser', function (done) {
const url =
'/unsupported-browser?fromURL=' + encodeURIComponent('/foo/bar/baz')
this.user.request(
{
url,
headers: {
'user-agent': unsupportedUserAgents.get('IE 11'),
},
},
(error, response) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
done()
}
)
})
it('shows the previous URL', function (done) {
const url = '/project/60867f47174dfd13f1e00000'
this.user.request(
{
url: '/unsupported-browser?fromURL=' + encodeURIComponent(url),
headers: {
'user-agent': unsupportedUserAgents.get('IE 11'),
},
},
(error, response, body) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
expect(body).to.include('URL:')
expect(body).to.include(url)
done()
}
)
})
it('shows a sanitized URL', function (done) {
const url = 'https://evil.com/the/pathname'
this.user.request(
{
url: '/unsupported-browser?fromURL=' + encodeURIComponent(url),
headers: {
'user-agent': unsupportedUserAgents.get('IE 11'),
},
},
(error, response, body) => {
expect(error).to.not.exist
expect(response.statusCode).to.equal(200)
expect(body).to.include('URL:')
expect(body).to.not.include('evil.com')
expect(body).to.include('/the/pathname')
done()
}
)
})
})

View File

@@ -0,0 +1,204 @@
import AuthenticationManager from '../../../app/src/Features/Authentication/AuthenticationManager.js'
import UserHelper from './helpers/UserHelper.mjs'
import Features from '../../../app/src/infrastructure/Features.js'
import { expect } from 'chai'
describe('UserHelper', function () {
// Disable all tests unless the registration feature is enabled
beforeEach(function () {
if (!Features.hasFeature('registration')) {
this.skip()
}
})
describe('UserHelper.createUser', function () {
describe('with no args', function () {
it('should create new user with default username and password', async function () {
const userHelper = await UserHelper.createUser()
userHelper.user.email.should.equal(userHelper.getDefaultEmail())
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
userHelper.getDefaultPassword(),
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
describe('with email', function () {
it('should create new user with provided email and default password', async function () {
const userHelper = await UserHelper.createUser({
email: 'foo@test.com',
})
userHelper.user.email.should.equal('foo@test.com')
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
userHelper.getDefaultPassword(),
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
describe('with password', function () {
it('should create new user with provided password and default email', async function () {
const userHelper = await UserHelper.createUser({
password: 'foofoofoo',
})
userHelper.user.email.should.equal(userHelper.getDefaultEmail())
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
'foofoofoo',
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
})
describe('UserHelper.getUser', function () {
let user
beforeEach(async function () {
user = (await UserHelper.createUser()).user
})
describe('with string id', function () {
it('should fetch user', async function () {
const userHelper = await UserHelper.getUser(user._id.toString())
userHelper.user.email.should.equal(user.email)
})
})
describe('with _id', function () {
it('should fetch user', async function () {
const userHelper = await UserHelper.getUser({ _id: user._id })
userHelper.user.email.should.equal(user.email)
})
})
})
describe('UserHelper.loginUser', function () {
let userHelper
beforeEach(async function () {
userHelper = await UserHelper.createUser()
})
describe('with email and password', function () {
it('should login user', async function () {
const newUserHelper = await UserHelper.loginUser({
email: userHelper.getDefaultEmail(),
password: userHelper.getDefaultPassword(),
})
newUserHelper.user.email.should.equal(userHelper.user.email)
})
})
describe('without email', function () {
it('should throw error', async function () {
await UserHelper.loginUser({
password: userHelper.getDefaultPassword(),
}).should.be.rejectedWith('email and password required')
})
})
describe('without password', function () {
it('should throw error', async function () {
await UserHelper.loginUser({
email: userHelper.getDefaultEmail(),
}).should.be.rejectedWith('email and password required')
})
})
describe('without email and password', function () {
it('should throw error', async function () {
await UserHelper.loginUser().should.be.rejectedWith(
'email and password required'
)
})
})
})
describe('UserHelper.registerUser', function () {
describe('with no args', function () {
it('should create new user with default username and password', async function () {
const userHelper = await UserHelper.registerUser()
userHelper.user.email.should.equal(userHelper.getDefaultEmail())
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
userHelper.getDefaultPassword(),
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
describe('with email', function () {
it('should create new user with provided email and default password', async function () {
const userHelper = await UserHelper.registerUser({
email: 'foo2@test.com',
})
userHelper.user.email.should.equal('foo2@test.com')
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
userHelper.getDefaultPassword(),
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
describe('with password', function () {
it('should create new user with provided password and default email', async function () {
const userHelper = await UserHelper.registerUser({
password: 'foofoofoo',
})
userHelper.user.email.should.equal(userHelper.getDefaultEmail())
const { user: authedUser } =
await AuthenticationManager.promises.authenticate(
{ _id: userHelper.user._id },
'foofoofoo',
null,
{ enforceHIBPCheck: false }
)
expect(authedUser).to.not.be.null
})
})
})
describe('getCsrfToken', function () {
it('should fetch csrfToken', async function () {
const userHelper = new UserHelper()
await userHelper.getCsrfToken()
expect(userHelper.csrfToken).to.be.a.string
})
})
describe('after logout', function () {
let userHelper, oldCsrfToken
beforeEach(async function () {
userHelper = await UserHelper.registerUser()
oldCsrfToken = userHelper.csrfToken
})
it('refreshes csrf token after logout', async function () {
await userHelper.logout()
expect(userHelper._csrfToken).to.equal('')
await userHelper.getCsrfToken()
expect(userHelper._csrfToken).to.not.equal('')
expect(userHelper._csrfToken).to.not.equal(oldCsrfToken)
})
})
})

View File

@@ -0,0 +1,172 @@
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
import Institution from './helpers/Institution.mjs'
import Subscription from './helpers/Subscription.mjs'
import Publisher from './helpers/Publisher.mjs'
import sinon from 'sinon'
import RecurlyClient from '../../../app/src/Features/Subscription/RecurlyClient.js'
describe('UserMembershipAuthorization', function () {
beforeEach(function (done) {
this.user = new User()
sinon.stub(RecurlyClient.promises, 'getSubscription').resolves({})
async.series([this.user.ensureUserExists.bind(this.user)], done)
})
afterEach(function () {
RecurlyClient.promises.getSubscription.restore()
})
describe('group', function () {
beforeEach(function (done) {
this.subscription = new Subscription({
groupPlan: true,
})
async.series(
[
this.subscription.ensureExists.bind(this.subscription),
cb => this.user.login(cb),
],
done
)
})
describe('users management', function () {
it('should allow managers only', function (done) {
const url = `/manage/groups/${this.subscription._id}/members`
async.series(
[
expectAccess(this.user, url, 403),
cb => this.subscription.setManagerIds([this.user._id], cb),
expectAccess(this.user, url, 200),
],
done
)
})
})
describe('managers management', function () {
it('should allow managers only', function (done) {
const url = `/manage/groups/${this.subscription._id}/managers`
async.series(
[
expectAccess(this.user, url, 403),
cb => this.subscription.setManagerIds([this.user._id], cb),
expectAccess(this.user, url, 200),
],
done
)
})
})
})
describe('institution', function () {
beforeEach(async function () {
this.institution = new Institution()
await this.institution.ensureExists(this.institution)
})
describe('users management', function () {
it('should allow managers only', function (done) {
const url = `/manage/institutions/${this.institution.v1Id}/managers`
async.series(
[
this.user.login.bind(this.user),
expectAccess(this.user, url, 403),
cb => this.institution.setManagerIds([this.user._id], cb),
expectAccess(this.user, url, 200),
],
done
)
})
})
describe('creation', function () {
it('should allow staff only', function (done) {
const url = `/entities/institution/create/foo`
async.series(
[
this.user.login.bind(this.user),
expectAccess(this.user, url, 403),
cb => this.user.ensureStaffAccess('institutionManagement', cb),
this.user.login.bind(this.user),
expectAccess(this.user, url, 200),
],
done
)
})
})
})
describe('publisher', function () {
beforeEach(function (done) {
this.publisher = new Publisher({})
async.series(
[
this.publisher.ensureExists.bind(this.publisher),
cb => this.user.login(cb),
],
done
)
})
describe('managers management', function () {
it('should allow managers only', function (done) {
const url = `/manage/publishers/${this.publisher.slug}/managers`
async.series(
[
expectAccess(this.user, url, 403),
cb => this.publisher.setManagerIds([this.user._id], cb),
expectAccess(this.user, url, 200),
],
done
)
})
})
describe('creation', function () {
it('should redirect staff only', function (done) {
const url = `/manage/publishers/foo/managers`
async.series(
[
this.user.login.bind(this.user),
expectAccess(this.user, url, 404),
cb => this.user.ensureStaffAccess('publisherManagement', cb),
this.user.login.bind(this.user),
expectAccess(this.user, url, 302, /\/create/),
],
done
)
})
it('should allow staff only', function (done) {
const url = `/entities/publisher/create/foo`
async.series(
[
expectAccess(this.user, url, 403),
cb => this.user.ensureStaffAccess('publisherManagement', cb),
this.user.login.bind(this.user),
expectAccess(this.user, url, 200),
],
done
)
})
})
})
})
function expectAccess(user, url, status, pattern) {
return callback => {
user.request.get({ url }, (error, response, body) => {
if (error) {
return callback(error)
}
expect(response.statusCode).to.equal(status)
if (pattern) {
expect(body).to.match(pattern)
}
callback()
})
}
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { expect } from 'chai'
import async from 'async'
import User from './helpers/User.mjs'
describe('User Must Reconfirm', function () {
beforeEach(function (done) {
this.user = new User()
return async.series(
[
this.user.ensureUserExists.bind(this.user),
cb => this.user.mongoUpdate({ $set: { must_reconfirm: true } }, cb),
],
done
)
})
it('should not allow sign in', function (done) {
return this.user.login(err => {
expect(err != null).to.equal(false)
return this.user.isLoggedIn((err, isLoggedIn) => {
expect(isLoggedIn).to.equal(false)
return done()
})
})
})
describe('Requesting reconfirmation email', function () {
it('should return a success to client for existing account', function (done) {
return this.user.reconfirmAccountRequest(
this.user.email,
(err, response) => {
expect(err != null).to.equal(false)
expect(response.statusCode).to.equal(200)
return done()
}
)
})
it('should return a 404 to client for non-existent account', function (done) {
return this.user.reconfirmAccountRequest(
'fake@overleaf.com',
(err, response) => {
expect(err != null).to.equal(false)
expect(response.statusCode).to.equal(404)
return done()
}
)
})
})
})

View File

@@ -0,0 +1,44 @@
import { expect } from 'chai'
import MockSubscription from './Subscription.mjs'
import SubscriptionUpdater from '../../../../app/src/Features/Subscription/SubscriptionUpdater.js'
import { Subscription as SubscriptionModel } from '../../../../app/src/models/Subscription.js'
import { DeletedSubscription as DeletedSubscriptionModel } from '../../../../app/src/models/DeletedSubscription.js'
class DeletedSubscription {
constructor(options = {}) {
this.subscription = new MockSubscription(options)
}
ensureExists(callback) {
this.subscription.ensureExists(error => {
if (error) {
return callback(error)
}
SubscriptionUpdater.deleteSubscription(this.subscription, {}, callback)
})
}
expectRestored(callback) {
DeletedSubscriptionModel.findOne({
'subscription._id': this.subscription._id,
})
.then(deletedSubscription => {
expect(deletedSubscription).to.be.null
SubscriptionModel.findById(this.subscription._id)
.then(subscription => {
expect(subscription).to.exist
expect(subscription._id.toString()).to.equal(
this.subscription._id.toString()
)
expect(subscription.admin_id.toString()).to.equal(
this.subscription.admin_id.toString()
)
callback()
})
.catch(callback)
})
.catch(callback)
}
}
export default DeletedSubscription

View File

@@ -0,0 +1,116 @@
import App from '../../../../app.mjs'
import QueueWorkers from '../../../../app/src/infrastructure/QueueWorkers.js'
import MongoHelper from './MongoHelper.mjs'
import RedisHelper from './RedisHelper.mjs'
import Settings from '@overleaf/settings'
import MockReCAPTCHAApi from '../mocks/MockReCaptchaApi.mjs'
import { gracefulShutdown } from '../../../../app/src/infrastructure/GracefulShutdown.js'
import Server from '../../../../app/src/infrastructure/Server.mjs'
import { injectRouteAfter } from './injectRoute.mjs'
import SplitTestHandler from '../../../../app/src/Features/SplitTests/SplitTestHandler.js'
import SplitTestSessionHandler from '../../../../app/src/Features/SplitTests/SplitTestSessionHandler.js'
import Modules from '../../../../app/src/infrastructure/Modules.js'
const app = Server.app
MongoHelper.initialize()
RedisHelper.initialize()
MockReCAPTCHAApi.initialize(2222)
let server
before('start main app', function (done) {
// We expose addition routes in the test environment for acceptance tests.
injectRouteAfter(
app,
route => route.path && route.path === '/dev/csrf',
router => {
router.get('/dev/session', (req, res) => {
// allow changing the session directly for testing, assign any
// properties in the query string to req.session
if (req.query && Object.keys(req.query).length > 0) {
Object.assign(req.session, req.query)
}
return res.json(req.session)
})
}
)
injectRouteAfter(
app,
route => route.path && route.path === '/dev/csrf',
router => {
router.post('/dev/set_in_session', (req, res) => {
for (const [key, value] of Object.entries(req.body)) {
req.session[key] = value
}
return res.sendStatus(200)
})
}
)
injectRouteAfter(
app,
route => route.path && route.path === '/dev/csrf',
router => {
router.get('/dev/split_test/get_assignment', (req, res) => {
const { splitTestName } = req.query
SplitTestHandler.promises
.getAssignment(req, res, splitTestName, {
sync: true,
})
.then(assignment => res.json(assignment))
.catch(error => {
res.status(500).json({ error: JSON.stringify(error) })
})
})
}
)
injectRouteAfter(
app,
route => route.path && route.path === '/dev/csrf',
router => {
router.post('/dev/split_test/session_maintenance', (req, res) => {
SplitTestSessionHandler.promises
.sessionMaintenance(req)
.then(res.sendStatus(200))
.catch(error => {
res.status(500).json({ error: JSON.stringify(error) })
})
})
}
)
injectRouteAfter(
app,
route => route.path && route.path === '/dev/csrf',
router => {
router.csrf.disableDefaultCsrfProtection(
'/dev/no_autostart_post_gateway',
'POST'
)
router.sessionAutostartMiddleware.disableSessionAutostartForRoute(
'/dev/no_autostart_post_gateway',
'POST',
(req, res, next) => {
next()
}
)
router.post('/dev/no_autostart_post_gateway', (req, res) => {
res.status(200).json({ message: 'no autostart' })
})
}
)
server = App.listen(23000, '127.0.0.1', done)
})
before('start queue workers', async function () {
QueueWorkers.start()
await Modules.start()
})
after('stop main app', async function () {
if (!server) {
return
}
Settings.gracefulShutdownDelayInMs = 1
await gracefulShutdown(server, 'tests')
})

View File

@@ -0,0 +1,37 @@
import mongodb from 'mongodb-legacy'
import { Institution as InstitutionModel } from '../../../../app/src/models/Institution.js'
const { ObjectId } = mongodb
let count = parseInt(Math.random() * 999999)
class Institution {
constructor(options = {}) {
this.v1Id = options.v1Id || count
this.managerIds = []
count += 1
}
async ensureExists() {
const filter = { v1Id: this.v1Id }
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
const institution = await InstitutionModel.findOneAndUpdate(
filter,
{},
options
)
this._id = institution._id
}
setManagerIds(managerIds, callback) {
return InstitutionModel.findOneAndUpdate(
{ _id: new ObjectId(this._id) },
{ managerIds }
)
.then((...args) => callback(null, ...args))
.catch(callback)
}
}
export default Institution

View File

@@ -0,0 +1,39 @@
import { execFile } from 'node:child_process'
import {
connectionPromise,
cleanupTestDatabase,
dropTestDatabase,
} from '../../../../app/src/infrastructure/mongodb.js'
import Settings from '@overleaf/settings'
const DEFAULT_ENV = 'saas'
export default {
initialize() {
before('wait for db', async function () {
await connectionPromise
})
if (process.env.CLEANUP_MONGO === 'true') {
before('drop test database', dropTestDatabase)
}
before('run migrations', function (done) {
const args = [
'run',
'migrations',
'--',
'migrate',
'-t',
Settings.env || DEFAULT_ENV,
]
execFile('npm', args, (error, stdout, stderr) => {
if (error) {
throw error
}
done()
})
})
afterEach('purge mongo data', cleanupTestDatabase)
},
}

View File

@@ -0,0 +1,40 @@
import mongodb from 'mongodb-legacy'
import { Publisher as PublisherModel } from '../../../../app/src/models/Publisher.js'
import { callbackifyClass } from '@overleaf/promise-utils'
const { ObjectId } = mongodb
let count = parseInt(Math.random() * 999999)
class PromisifiedPublisher {
constructor(options = {}) {
this.slug = options.slug || `publisher-slug-${count}`
this.managerIds = []
count += 1
}
async ensureExists() {
const filter = { slug: this.slug }
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
const publisher = await PublisherModel.findOneAndUpdate(
filter,
{},
options
).exec()
this._id = publisher._id
}
async setManagerIds(managerIds) {
return await PublisherModel.findOneAndUpdate(
{ _id: new ObjectId(this._id) },
{ managerIds }
).exec()
}
}
const Publisher = callbackifyClass(PromisifiedPublisher)
Publisher.promises = class extends PromisifiedPublisher {}
export default Publisher

View File

@@ -0,0 +1,65 @@
import mongodb from 'mongodb-legacy'
import Subscription from './Subscription.mjs'
import MockRecurlyApiClass from '../mocks/MockRecurlyApi.mjs'
import RecurlyWrapper from '../../../../app/src/Features/Subscription/RecurlyWrapper.js'
import { promisifyClass } from '@overleaf/promise-utils'
const { ObjectId } = mongodb
let MockRecurlyApi
before(function () {
MockRecurlyApi = MockRecurlyApiClass.instance()
})
class RecurlySubscription {
constructor(options = {}) {
options.recurlySubscription_id = new ObjectId().toString()
this.subscription = new Subscription(options)
this.uuid = options.recurlySubscription_id
this.state = options.state || 'active'
this.tax_in_cents = 100
this.tax_rate = 0.2
this.unit_amount_in_cents = 500
this.currency = 'GBP'
this.current_period_ends_at = new Date(2018, 4, 5)
this.trial_ends_at = new Date(2018, 6, 7)
this.account = {
id: this.subscription.admin_id.toString(),
email: options.account && options.account.email,
hosted_login_token: options.account && options.account.hosted_login_token,
}
this.planCode = options.planCode || 'personal'
}
ensureExists(callback) {
this.subscription.ensureExists(error => {
if (error) {
return callback(error)
}
MockRecurlyApi.addMockSubscription(this)
callback()
})
}
buildCallbackXml(event) {
return RecurlyWrapper._buildXml(event, {
subscription: {
uuid: this.uuid,
state: this.state,
plan: {
plan_code: this.planCode,
},
},
account: {
account_code: this.account.id,
},
})
}
}
export default RecurlySubscription
export const promises = promisifyClass(RecurlySubscription, {
without: ['buildCallbackXml'],
})

View File

@@ -0,0 +1,10 @@
import RedisWrapper from '../../../../app/src/infrastructure/RedisWrapper.js'
const client = RedisWrapper.client('ratelimiter')
export default {
initialize() {
beforeEach('clear redis', function (done) {
client.flushdb(done)
})
},
}

View File

@@ -0,0 +1,254 @@
import fs from 'node:fs'
import path from 'node:path'
import { SignedXml } from 'xml-crypto'
import { SamlLog } from '../../../../app/src/models/SamlLog.js'
import { expect } from 'chai'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'
import xml2js from 'xml2js'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const samlDataDefaults = {
firstName: 'first-name',
hasEntitlement: 'Y',
issuer: 'Overleaf',
lastName: 'last-name',
requestId: 'dummy-request-id',
}
function samlValue(val) {
if (!Array.isArray(val)) {
val = [val]
}
return val
.map(
v =>
`<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${v}</saml:AttributeValue>`
)
.join('')
}
function makeAttribute(attribute, value) {
if (!value) {
return ''
}
return `<saml:AttributeStatement>
<saml:Attribute Name="${attribute}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
${samlValue(value)}
</saml:Attribute>
</saml:AttributeStatement>`
}
function createMockSamlAssertion(samlData = {}, opts = {}) {
const {
email,
firstName,
hasEntitlement,
issuer,
lastName,
uniqueId,
requestId,
} = {
...samlDataDefaults,
...samlData,
}
const { signedAssertion = true } = opts
const userIdAttributeName = samlData.userIdAttribute || 'uniqueId'
const userIdAttribute =
uniqueId &&
`<saml:AttributeStatement>
<saml:Attribute Name="${userIdAttributeName}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${uniqueId}</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>`
const userIdAttributeLegacy =
samlData.userIdAttributeLegacy && samlData.uniqueIdLegacy
? `<saml:AttributeStatement><saml:Attribute Name="${samlData.userIdAttributeLegacy}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${samlData.uniqueIdLegacy}</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>`
: ''
const nameId =
userIdAttributeName && userIdAttributeName !== 'nameID'
? `<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">mock@email.com</saml:NameID>`
: ''
const samlAssertion = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="t835VaiI1fph1yk8yhdD4OtyBQ4" IssueInstant="2018-08-09T08:56:30.126Z" Version="2.0">
<saml:Issuer>${issuer}</saml:Issuer>
<saml:Subject>
${nameId}
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="*******" NotOnOrAfter="2028-08-09T09:01:30.126Z" InResponseTo="${requestId}" />
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2008-08-09T08:51:30.126Z" NotOnOrAfter="2028-08-09T09:01:30.126Z">
<saml:AudienceRestriction>
<saml:Audience>${issuer}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement SessionIndex="t835VaiI1fph1yk8yhdD4OtyBQ4" AuthnInstant="2018-08-09T08:56:30.118Z">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
${makeAttribute('email', email)}
${makeAttribute('firstName', firstName)}
<saml:AttributeStatement>
<saml:Attribute Name="hasEntitlement" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${hasEntitlement}</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
<saml:AttributeStatement>
<saml:Attribute Name="issuer" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${issuer}</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
${makeAttribute('lastName', lastName)}
${userIdAttribute}
${userIdAttributeLegacy}
</saml:Assertion>`
if (!signedAssertion) {
return samlAssertion
}
const sig = new SignedXml()
sig.addReference(
"//*[local-name(.)='Assertion']",
[
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
'http://www.w3.org/2001/10/xml-exc-c14n#',
],
'http://www.w3.org/2000/09/xmldsig#sha1'
)
sig.signingKey = fs.readFileSync(
path.resolve(__dirname, '../../files/saml-key.pem'),
'utf8'
)
sig.computeSignature(samlAssertion)
return sig.getSignedXml()
}
function createMockSamlResponse(samlData = {}, opts = {}) {
const { issuer, requestId } = {
...samlDataDefaults,
...samlData,
}
const { signedResponse = true } = opts
const samlAssertion = createMockSamlAssertion(samlData, opts)
let samlResponse = `
<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Version="2.0" ID="WQMXUw8BBp4_XWzcuKgaN5tmxpT" IssueInstant="2018-08-09T08:56:30.106Z" InResponseTo="${requestId}">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${issuer}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
${samlAssertion}
</samlp:Response>
`
if (signedResponse) {
const sig = new SignedXml()
sig.addReference(
"//*[local-name(.)='Response']",
[
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
'http://www.w3.org/2001/10/xml-exc-c14n#',
],
'http://www.w3.org/2000/09/xmldsig#sha1'
)
sig.signingKey = fs.readFileSync(
path.resolve(__dirname, '../../files/saml-key.pem'),
'utf8'
)
sig.computeSignature(samlResponse)
samlResponse = sig.getSignedXml()
}
return Buffer.from(samlResponse).toString('base64')
}
function samlUniversity(config = {}) {
return {
hostname: 'example-sso.com',
sso_cert: fs
.readFileSync(
path.resolve(__dirname, '../../files/saml-cert.crt'),
'utf8'
)
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\n/g, ''),
sso_enabled: true,
sso_entry_point: 'http://example-sso.com/saml',
sso_entity_id: 'http://example-sso.com/saml/idp',
university_id: 9999,
university_name: 'Example University',
sso_user_email_attribute: 'email',
sso_user_first_name_attribute: 'firstName',
sso_user_id_attribute: 'uniqueId',
sso_user_last_name_attribute: 'lastName',
sso_license_entitlement_attribute: 'hasEntitlement',
sso_license_entitlement_matcher: 'Y',
sso_signature_algorithm: 'sha256',
...config,
}
}
async function getParseAndDoChecksForSamlLogs(numberOfLog) {
const logs = await SamlLog.find({}, {})
.sort({ $natural: -1 })
.limit(numberOfLog || 1)
.exec()
logs.forEach(log => {
expect(log.sessionId).to.exist
expect(log.sessionId.length).to.equal(8) // not full session ID
expect(log.createdAt).to.exist
expect(log.jsonData).to.exist
log.parsedJsonData = JSON.parse(log.jsonData)
if (log.samlAssertion) {
log.parsedSamlAssertion = JSON.parse(log.samlAssertion)
}
})
return logs
}
/**
* Parses a SAML request from a redirect URI.
*
* @param {URL} redirectUri - The redirect URI containing the SAML request.
* @returns {Promise<Object>} - A promise that resolves to the parsed SAML request object.
*/
async function parseSamlRequest(redirectUri) {
const decoded = redirectUri.searchParams.get('SAMLRequest')
const base64Decoded = Buffer.from(decoded, 'base64')
const inflated = zlib.inflateRawSync(base64Decoded)
return xml2js.parseStringPromise(inflated.toString('utf8'))
}
/**
* Parses the SAML request from the given redirect URI and returns the request ID.
* @param {URL} redirectUri - The redirect URI containing the SAML request.
* @returns {Promise<string>} - A Promise that resolves to the request ID.
*/
async function getRequestId(redirectUri) {
const samlRequest = await parseSamlRequest(redirectUri)
return samlRequest['samlp:AuthnRequest'].$.ID
}
const SAMLHelper = {
createMockSamlResponse,
samlUniversity,
getParseAndDoChecksForSamlLogs,
parseSamlRequest,
getRequestId,
}
export default SAMLHelper

View File

@@ -0,0 +1,60 @@
import { assert } from 'chai'
import { CacheFlow } from 'cache-flow'
const sendStaffRequest = async function (
staffUser,
{ method, path, payload, clearCache = true }
) {
const response = await staffUser.doRequest(method, {
uri: path,
json: payload,
})
if (clearCache) {
await CacheFlow.reset('split-test')
}
return response
}
const createTest = async function (staffUser, payload) {
const response = await sendStaffRequest(staffUser, {
method: 'POST',
path: '/admin/api/split-test/create',
payload,
})
return response.body
}
const updateTestConfig = async function (staffUser, payload) {
const response = await sendStaffRequest(staffUser, {
method: 'POST',
path: '/admin/api/split-test/update-config',
payload,
})
return response.body
}
const expectResponse = async function (
staffUser,
{ method, path, payload },
{ status, body, excluding, excludingEvery }
) {
const result = await sendStaffRequest(staffUser, { method, path, payload })
assert.equal(result.response.statusCode, status)
if (body) {
if (excludingEvery) {
assert.deepEqualExcludingEvery(result.body, body, excludingEvery)
} else if (excluding) {
assert.deepEqualExcludingEvery(result.body, body, excluding)
} else {
assert.deepEqual(result.body, body)
}
}
}
export default {
sendStaffRequest,
createTest,
updateTestConfig,
expectResponse,
}

View File

@@ -0,0 +1,181 @@
import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js'
import { expect } from 'chai'
import { callbackifyClass } from '@overleaf/promise-utils'
import SubscriptionUpdater from '../../../../app/src/Features/Subscription/SubscriptionUpdater.js'
import PermissionsManager from '../../../../app/src/Features/Authorization/PermissionsManager.js'
import SSOConfigManager from '../../../../modules/group-settings/app/src/sso/SSOConfigManager.mjs'
import { Subscription as SubscriptionModel } from '../../../../app/src/models/Subscription.js'
import { DeletedSubscription as DeletedSubscriptionModel } from '../../../../app/src/models/DeletedSubscription.js'
import Modules from '../../../../app/src/infrastructure/Modules.js'
class PromisifiedSubscription {
constructor(options = {}) {
this.admin_id = options.adminId || new ObjectId()
this.overleaf = options.overleaf || {}
this.groupPlan = options.groupPlan
this.manager_ids = options.managerIds || [this.admin_id]
this.member_ids = options.memberIds || []
this.membersLimit = options.membersLimit || 0
this.invited_emails = options.invitedEmails || []
this.teamName = options.teamName
this.teamInvites = options.teamInvites || []
this.planCode = options.planCode
this.recurlySubscription_id = options.recurlySubscription_id
this.features = options.features
this.ssoConfig = options.ssoConfig
this.groupPolicy = options.groupPolicy
this.addOns = options.addOns
this.paymentProvider = options.paymentProvider
}
async ensureExists() {
if (this._id) {
return null
}
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
const subscription = await SubscriptionModel.findOneAndUpdate(
{ admin_id: this.admin_id },
this,
options
).exec()
this._id = subscription._id
}
async get() {
return await db.subscriptions.findOne({ _id: new ObjectId(this._id) })
}
async getWithGroupPolicy() {
// eslint-disable-next-line no-restricted-syntax
return await SubscriptionModel.findById(this._id)
.populate('groupPolicy')
.exec()
}
async setManagerIds(managerIds) {
return await SubscriptionModel.findOneAndUpdate(
{ _id: new ObjectId(this._id) },
{ manager_ids: managerIds }
)
}
async setSSOConfig(ssoConfig) {
const subscription = await this.get()
return await SSOConfigManager.promises.updateSubscriptionSSOConfig(
subscription,
ssoConfig
)
}
async refreshUsersFeatures() {
return await SubscriptionUpdater.promises.refreshUsersFeatures(this)
}
async enableManagedUsers() {
await Modules.promises.hooks.fire('enableManagedUsers', this._id)
}
async enableFeatureSSO() {
await SubscriptionModel.findOneAndUpdate(
{ _id: new ObjectId(this._id) },
{ 'features.groupSSO': true }
).exec()
}
async setValidatedSSO() {
const doc = await db.subscriptions.findOne({ _id: new ObjectId(this._id) })
const ssoConfigId = doc.ssoConfig
return await db.ssoConfigs.findOneAndUpdate(
{ _id: ssoConfigId },
{ $set: { validated: true } }
)
}
async setValidatedAndEnabledSSO() {
const doc = await db.subscriptions.findOne({ _id: new ObjectId(this._id) })
const ssoConfigId = doc.ssoConfig
return await db.ssoConfigs.findOneAndUpdate(
{ _id: ssoConfigId },
{ $set: { enabled: true, validated: true } }
)
}
async getEnrollmentForUser(user) {
const [enrollment] = await Modules.promises.hooks.fire(
'getManagedUsersEnrollmentForUser',
user
)
return enrollment
}
getCapabilities(groupPolicy) {
return PermissionsManager.getUserCapabilities(groupPolicy)
}
async getUserValidationStatus(params) {
return await PermissionsManager.promises.getUserValidationStatus(params)
}
async enrollManagedUser(user) {
const subscription = await SubscriptionModel.findById(this._id).exec()
return await Modules.promises.hooks.fire(
'enrollInManagedSubscription',
user._id,
subscription
)
}
async expectDeleted(deleterData) {
const deletedSubscriptions = await DeletedSubscriptionModel.find({
'subscription._id': this._id,
}).exec()
expect(deletedSubscriptions.length).to.equal(1)
const deletedSubscription = deletedSubscriptions[0]
expect(deletedSubscription.subscription.teamInvites).to.be.empty
expect(deletedSubscription.subscription.invited_emails).to.be.empty
expect(deletedSubscription.deleterData.deleterIpAddress).to.equal(
deleterData.ip
)
if (deleterData.id) {
expect(deletedSubscription.deleterData.deleterId.toString()).to.equal(
deleterData.id.toString()
)
} else {
expect(deletedSubscription.deleterData.deleterId).to.be.undefined
}
const subscription = await SubscriptionModel.findById(this._id).exec()
expect(subscription).to.be.null
}
async addMember(userId) {
return await SubscriptionModel.findOneAndUpdate(
{ _id: new ObjectId(this._id) },
{ $push: { member_ids: userId } }
).exec()
}
async inviteUser(adminUser, email) {
await adminUser.login()
return await adminUser.doRequest('POST', {
url: `/manage/groups/${this._id}/invites`,
json: {
email,
},
})
}
}
const Subscription = callbackifyClass(PromisifiedSubscription, {
without: ['getCapabilities'],
})
Subscription.promises = class extends PromisifiedSubscription {}
export default Subscription

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
import { CookieJar } from 'tough-cookie'
import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
import Settings from '@overleaf/settings'
import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js'
import UserCreator from '../../../../app/src/Features/User/UserCreator.js'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
import moment from 'moment'
import fetch from 'node-fetch'
import { db } from '../../../../app/src/infrastructure/mongodb.js'
import mongodb from 'mongodb-legacy'
import { UserAuditLogEntry } from '../../../../app/src/models/UserAuditLogEntry.js'
// Import the rate limiter so we can clear it between tests
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.js'
const { ObjectId } = mongodb
const rateLimiters = {
resendConfirmation: new RateLimiter('resend-confirmation'),
}
let globalUserNum = Settings.test.counterInit
class UserHelper {
/**
* Create UserHelper
* @param {object} [user] - Mongo User object
*/
constructor(user = null) {
// used for constructing default emails, etc
this.userNum = globalUserNum++
// initialize all internal state properties to defaults
this.reset()
// set user if passed in, may be null
this.user = user
}
/* sync functions */
/**
* Get auditLog, ignore the login
* @return {object[]}
*/
getAuditLogWithoutNoise() {
return (this.user.auditLog || []).filter(entry => {
return entry.operation !== 'login'
})
}
/**
* Generate default email from unique (per instantiation) user number
* @returns {string} email
*/
getDefaultEmail() {
return `test.user.${this.userNum}@example.com`
}
/**
* Generate email, password args object. Default values will be used if
* email and password are not passed in args.
* @param {object} [userData]
* @param {string} [userData.email] email to use
* @param {string} [userData.password] password to use
* @returns {object} email, password object
*/
getDefaultEmailPassword(userData = {}) {
return {
email: this.getDefaultEmail(),
password: this.getDefaultPassword(),
...userData,
}
}
/**
* Generate default password from unique (per instantiation) user number
* @returns {string} password
*/
getDefaultPassword() {
return `New-Password-${this.userNum}!`
}
/**
* (Re)set internal state of UserHelper object.
*/
reset() {
// cached csrf token
this._csrfToken = ''
// used to store mongo user object once created/loaded
this.user = null
// cookie jar
this.jar = new CookieJar()
}
async fetch(url, opts = {}) {
url = UserHelper.url(url)
const headers = {}
const cookieString = this.jar.getCookieStringSync(url.toString())
if (cookieString) {
headers.Cookie = cookieString
}
if (this._csrfToken) {
headers['x-csrf-token'] = this._csrfToken
}
const response = await fetch(url, {
redirect: 'manual',
...opts,
headers: { ...headers, ...opts.headers },
})
// From https://www.npmjs.com/package/node-fetch#extract-set-cookie-header
const cookies = response.headers.raw()['set-cookie']
if (cookies != null) {
for (const cookie of cookies) {
this.jar.setCookieSync(cookie, url.toString())
}
}
return response
}
/* async http api call methods */
/**
* Requests csrf token unless already cached in internal state
*/
async getCsrfToken() {
// get csrf token from api and store
const response = await this.fetch('/dev/csrf')
const body = await response.text()
if (response.status !== 200) {
throw new Error(
`get csrf token failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
this._csrfToken = body
}
/**
* Requests user session
*/
async getSession() {
const response = await this.fetch('/dev/session')
const body = await response.text()
if (response.status !== 200) {
throw new Error(
`get session failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
return JSON.parse(body)
}
async getSplitTestAssignment(splitTestName) {
const response = await this.fetch(
`/dev/split_test/get_assignment?splitTestName=${splitTestName}`
)
const body = await response.text()
if (response.status !== 200) {
throw new Error(
`get split test assignment failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
return JSON.parse(body)
}
async getEmailConfirmationCode() {
const session = await this.getSession()
const code = session.pendingUserRegistration?.confirmCode
if (!code) {
throw new Error('No confirmation code found in session')
}
return code
}
/**
* Make request to POST /logout
* @param {object} [options] options to pass to request
* @returns {object} http response
*/
async logout(options = {}) {
// post logout
const response = await this.fetch('/logout', { method: 'POST', ...options })
if (
response.status !== 302 ||
!response.headers.get('location').includes('/login')
) {
const body = await response.text()
throw new Error(
`logout failed: status=${response.status} body=${JSON.stringify(
body
)} headers=${JSON.stringify(
Object.fromEntries(response.headers.entries())
)}`
)
}
// after logout CSRF token becomes invalid
this._csrfToken = ''
// resolve with http request response
return response
}
/* static sync methods */
/**
* Generates base URL from env options
* @returns {string} baseUrl
*/
static baseUrl() {
return `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
}
/**
* Generates a full URL given a path
*/
static url(path) {
return new URL(path, UserHelper.baseUrl())
}
/* static async instantiation methods */
/**
* Create a new user via UserCreator and return UserHelper instance
* @param {object} attributes user data for UserCreator
* @param {object} options options for UserCreator
* @returns {UserHelper}
*/
static async createUser(attributes = {}) {
const userHelper = new UserHelper()
attributes = userHelper.getDefaultEmailPassword(attributes)
// hash password and delete plaintext if set
if (attributes.password) {
attributes.hashedPassword =
await AuthenticationManager.promises.hashPassword(attributes.password)
delete attributes.password
}
userHelper.user = await UserCreator.promises.createNewUser(attributes)
return userHelper
}
/**
* Get existing user via UserGetter and return UserHelper instance.
* All args passed to UserGetter.getUser.
* @returns {UserHelper}
*/
static async getUser(...args) {
const user = await UserGetter.promises.getUser(...args)
if (!user) {
throw new Error(`no user found for args: ${JSON.stringify([...args])}`)
}
user.auditLog = await UserAuditLogEntry.find(
{ userId: user._id },
{},
{ sort: { timestamp: 'asc' } }
).exec()
return new UserHelper(user)
}
/**
* Update an existing user via UserUpdater and return the updated UserHelper
* instance.
* All args passed to UserUpdater.getUser.
* @returns {UserHelper}
*/
static async updateUser(userId, update) {
// TODO(das7pad): revert back to args pass-through after mongo upgrades
const user = await UserUpdater.promises.updateUser(
{ _id: new ObjectId(userId) },
update
)
if (!user) {
throw new Error(`no user found for args: ${JSON.stringify([userId])}`)
}
return new UserHelper(user)
}
/**
* Login to existing account via request and return UserHelper instance
* @param {object} userData
* @param {string} userData.email
* @param {string} userData.password
* @returns {UserHelper}
*/
static async loginUser(userData, expectedRedirect) {
if (!userData || !userData.email || !userData.password) {
throw new Error('email and password required')
}
const userHelper = new UserHelper()
const loginPath = Settings.enableLegacyLogin ? '/login/legacy' : '/login'
await userHelper.getCsrfToken()
const response = await userHelper.fetch(loginPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
'g-recaptcha-response': 'valid',
...userData,
}),
})
if (!response.ok) {
const body = await response.text()
const error = new Error(
`login failed: status=${response.status} body=${JSON.stringify(body)}`
)
error.response = response
throw error
}
const body = await response.json()
if (
body.redir !== '/project' &&
expectedRedirect &&
body.redir !== expectedRedirect
) {
const error = new Error(
`login should redirect to /project: status=${
response.status
} body=${JSON.stringify(body)}`
)
error.response = response
throw error
}
userHelper.user = await UserGetter.promises.getUser({
email: userData.email,
})
if (!userHelper.user) {
throw new Error(`user not found for email: ${userData.email}`)
}
await userHelper.getCsrfToken()
return userHelper
}
/**
* Check if user is logged in by requesting an endpoint behind authentication.
* @returns {Boolean}
*/
async isLoggedIn() {
const response = await this.fetch('/user/sessions', {
redirect: 'follow',
})
return !response.redirected
}
/**
* Register new account via request and return UserHelper instance.
* If userData is not provided the default email and password will be used.
* @param {object} [userData]
* @param {string} [userData.email]
* @param {string} [userData.password]
* @returns {UserHelper}
*/
static async registerUser(userData, options = {}) {
const userHelper = new UserHelper()
await userHelper.getCsrfToken()
userData = userHelper.getDefaultEmailPassword(userData)
const response = await userHelper.fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(userData),
...options,
})
const body = await response.json()
if (response.status !== 200) {
throw new Error(
`register failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
if (body.message && body.message.type === 'error') {
throw new Error(`register api error: ${body.message.text}`)
}
if (body.redir === '/sso-login') {
throw new Error(
`cannot register intitutional email: ${options.json.email}`
)
}
const code = await userHelper.getEmailConfirmationCode()
const confirmationResponse = await userHelper.fetch(
'/registration/confirm-email',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ code }),
...options,
}
)
if (confirmationResponse.status !== 200) {
throw new Error(
`email confirmation failed: status=${
response.status
} body=${JSON.stringify(body)}`
)
}
userHelper.user = await UserGetter.promises.getUser({
email: userData.email,
})
if (!userHelper.user) {
throw new Error(`user not found for email: ${userData.email}`)
}
await userHelper.getCsrfToken()
return userHelper
}
async refreshMongoUser() {
this.user = await UserGetter.promises.getUser({
_id: this.user._id,
})
return this.user
}
async addEmail(email) {
const response = await this.fetch('/user/emails', {
method: 'POST',
body: new URLSearchParams([['email', email]]),
})
const body = await response.text()
if (response.status !== 204) {
throw new Error(
`add email failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
}
async addEmailAndConfirm(userId, email) {
await this.addEmail(email)
await this.confirmEmail(userId, email)
}
async changeConfirmationDate(userId, email, date) {
const query = {
_id: userId,
'emails.email': email,
}
const update = {
$set: {
'emails.$.confirmedAt': date,
'emails.$.reconfirmedAt': date,
},
}
await UserUpdater.promises.updateUser(query, update)
await InstitutionsAPI.promises.addAffiliation(userId, email, {
confirmedAt: date,
})
}
async changeConfirmedToNotificationPeriod(
userId,
email,
maxConfirmationMonths
) {
// set a user's confirmation date so that
// it is within the notification period to reconfirm
// but not older than the last day to reconfirm
const notificationDays = Settings.reconfirmNotificationDays
if (!notificationDays) return
const middleOfNotificationPeriod = Math.ceil(notificationDays / 2)
// use the middle of the notification rather than the start or end due to
// variations in days in months.
const lastDayToReconfirm = moment().subtract(
maxConfirmationMonths,
'months'
)
const notificationsStart = lastDayToReconfirm
.add(middleOfNotificationPeriod, 'days')
.toDate()
await this.changeConfirmationDate(userId, email, notificationsStart)
}
async changeConfirmedToPastReconfirmation(
userId,
email,
maxConfirmationMonths
) {
// set a user's confirmation date so that they are past the reconfirmation window
const date = moment()
.subtract(maxConfirmationMonths, 'months')
.subtract(1, 'week')
.toDate()
await this.changeConfirmationDate(userId, email, date)
}
async confirmEmail(userId, email) {
// clear ratelimiting on resend confirmation endpoint
await rateLimiters.resendConfirmation.delete(userId)
// UserHelper.createUser does not create a confirmation token
let response = await this.fetch('/user/emails/resend_confirmation', {
method: 'POST',
body: new URLSearchParams([['email', email]]),
})
if (response.status !== 200) {
const body = await response.text()
throw new Error(
`resend confirmation failed: status=${
response.status
} body=${JSON.stringify(body)}`
)
}
const tokenData = await db.tokens
.find({
use: 'email_confirmation',
'data.user_id': userId.toString(),
'data.email': email,
usedAt: { $exists: false },
})
.next()
response = await this.fetch('/user/emails/confirm', {
method: 'POST',
body: new URLSearchParams([['token', tokenData.token]]),
})
if (response.status !== 200) {
const body = await response.text()
throw new Error(
`confirm email failed: status=${response.status} body=${JSON.stringify(
body
)}`
)
}
}
}
export default UserHelper

View File

@@ -0,0 +1,22 @@
import { expect } from 'chai'
export default {
requireLogin: {
json(response, body) {
expect(response.statusCode).to.equal(401)
expect(body).to.equal('Unauthorized')
expect(response.headers['www-authenticate']).to.equal('OverleafLogin')
},
},
restricted: {
html(response, body) {
expect(response.statusCode).to.equal(403)
expect(body).to.match(/<head><title>Restricted/)
},
json(response, body) {
expect(response.statusCode).to.equal(403)
expect(body).to.deep.equal({ message: 'restricted' })
},
},
}

View File

@@ -0,0 +1,220 @@
import fs from 'node:fs'
import Path from 'node:path'
import UserModule from './User.mjs'
import SubscriptionHelper from './Subscription.mjs'
import { SSOConfig } from '../../../../app/src/models/SSOConfig.js'
import UserHelper from './UserHelper.mjs'
import SAMLHelper from './SAMLHelper.mjs'
import Settings from '@overleaf/settings'
import { getProviderId } from '../../../../app/src/Features/Subscription/GroupUtils.js'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import { fileURLToPath } from 'node:url'
import { Subscription as SubscriptionModel } from '../../../../app/src/models/Subscription.js'
const { promises: User } = UserModule
const { promises: Subscription } = SubscriptionHelper
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const SAML_TEST_CERT = fs
.readFileSync(Path.resolve(__dirname, '../../files/saml-cert.crt'), 'utf8')
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\n/g, '')
function getEnrollmentUrl(groupId) {
return `/subscription/${groupId}/sso_enrollment`
}
const userIdAttribute = 'nameID'
export const baseSsoConfig = {
entryPoint: 'http://example-sso.com/saml',
certificates: [SAML_TEST_CERT],
signatureAlgorithm: 'sha256',
userIdAttribute,
} // the database also sets enabled and validated, but we cannot set that in the POST request for /manage/groups/:ID/settings/sso
export async function createGroupSSO() {
const nonSSOMemberHelper = await UserHelper.createUser()
const nonSSOMember = nonSSOMemberHelper.user
const groupAdminUser = new User()
const memberUser = new User()
await groupAdminUser.ensureUserExists()
await memberUser.ensureUserExists()
const ssoConfig = new SSOConfig({
...baseSsoConfig,
enabled: true,
validated: true,
})
await ssoConfig.save()
const subscription = new Subscription({
adminId: groupAdminUser._id,
memberIds: [memberUser._id, nonSSOMember._id, groupAdminUser._id],
groupPlan: true,
planCode: 'group_professional_10_enterprise',
features: {
groupSSO: true,
},
ssoConfig: ssoConfig._id,
membersLimit: 10,
})
await subscription.ensureExists()
const subscriptionId = subscription._id.toString()
const enrollmentUrl = getEnrollmentUrl(subscriptionId)
const internalProviderId = getProviderId(subscriptionId)
await linkGroupMember(
memberUser.email,
memberUser.password,
subscriptionId,
'mock@email.com'
)
const userHelper = new UserHelper()
return {
ssoConfig,
internalProviderId,
userIdAttribute,
subscription,
subscriptionId,
groupAdminUser,
memberUser,
nonSSOMemberHelper,
nonSSOMember,
userHelper,
enrollmentUrl,
}
}
export async function linkGroupMember(
userEmail,
userPassword,
groupId,
externalUserId
) {
// eslint-disable-next-line no-restricted-syntax
const subscription = await SubscriptionModel.findById(groupId)
.populate('ssoConfig')
.exec()
const userIdAttribute = subscription?.ssoConfig?.userIdAttribute
const internalProviderId = getProviderId(groupId)
const enrollmentUrl = getEnrollmentUrl(groupId)
const userHelper = await UserHelper.loginUser(
{
email: userEmail,
password: userPassword,
},
`/subscription/${groupId}/sso_enrollment`
)
const { headers } = await userHelper.fetch(enrollmentUrl, {
method: 'POST',
})
if (
!headers.get('location') ||
!headers.get('location').includes(Settings.saml.groupSSO.initPath)
) {
throw new Error('invalid redirect when linking to group SSO')
}
const redirectTo = new URL(headers.get('location'))
const initSSOResponse = await userHelper.fetch(redirectTo)
// redirect to IdP
const idpEntryPointUrl = new URL(initSSOResponse.headers.get('location'))
const requestId = await SAMLHelper.getRequestId(idpEntryPointUrl)
const response = await userHelper.fetch(Settings.saml.groupSSO.path, {
method: 'POST',
body: new URLSearchParams({
SAMLResponse: SAMLHelper.createMockSamlResponse({
requestId,
userIdAttribute,
uniqueId: externalUserId,
issuer: 'https://www.overleaf.test/saml/group-sso/meta',
}),
}),
})
if (response.status !== 302) {
throw new Error('failed to link group SSO')
}
// ensure user linked
const user = await UserGetter.promises.getUser(
{ email: userEmail },
{ samlIdentifiers: 1, enrollment: 1 }
)
const { enrollment, samlIdentifiers } = user
const linkedToGroupSSO = samlIdentifiers.some(
identifier => identifier.providerId === internalProviderId
)
const userIsEnrolledInSSO = enrollment.sso.some(
sso => sso.groupId.toString() === groupId.toString()
)
if (!linkedToGroupSSO || !userIsEnrolledInSSO) {
throw new Error('error setting up test user with group SSO linked')
}
return userHelper
}
export async function setConfigAndEnableSSO(
subscriptionHelper,
adminEmailPassword,
config
) {
config = config || {
entryPoint: 'http://idp.example.com/entry_point',
certificates: [SAML_TEST_CERT],
userIdAttribute: 'email',
userLastNameAttribute: 'lastName',
}
const { email, password } = adminEmailPassword
const userHelper = await UserHelper.loginUser({
email,
password,
})
const createResponse = await userHelper.fetch(
`/manage/groups/${subscriptionHelper._id}/settings/sso`,
{
method: 'POST',
body: JSON.stringify(config),
headers: {
'Content-Type': 'application/json',
},
}
)
if (createResponse.status !== 201) {
throw new Error(
`failed to set SSO config. Status = ${createResponse.status}`
)
}
await subscriptionHelper.setValidatedSSO()
const enableResponse = await userHelper.fetch(
`/manage/groups/${subscriptionHelper._id}/settings/enableSSO`,
{ method: 'POST' }
)
if (enableResponse.status !== 200) {
throw new Error(`failed to enable SSO. Status = ${enableResponse.status}`)
}
}
export default {
createGroupSSO,
linkGroupMember,
baseSsoConfig,
setConfigAndEnableSSO,
}

View File

@@ -0,0 +1,39 @@
/**
* Used to inject an endpoint into our app that should only be available
* when running in the test environment.
*
* @param app - a reference to the app.
* @param searchFilter - a filter function to locate a route to position the new route immediatley after.
* @param addRouteCallback - a callback that takes a router and creates the new route.
*/
export function injectRouteAfter(app, searchFilter, addRouteCallback) {
const stack = app._router.stack
stack.forEach(layer => {
if (layer.name !== 'router' || !layer.handle || !layer.handle.stack) {
return
}
// Find the route that we want to position out new route after.
const newRouteIndex = layer.handle.stack.findIndex(
route => route && route.route && searchFilter(route.route)
)
if (newRouteIndex !== -1) {
// Add our new endpoint to the end of the router stack.
addRouteCallback(layer.handle)
const routeStack = layer.handle.stack
const sessionRoute = routeStack[routeStack.length - 1]
// Then we reposition it next to the route we found previously.
layer.handle.stack = [
...routeStack.slice(0, newRouteIndex),
sessionRoute,
...routeStack.slice(newRouteIndex, routeStack.length - 1),
]
}
})
}
export default { injectRouteAfter }

View File

@@ -0,0 +1,25 @@
import { callbackify } from 'node:util'
import request from './request.js'
import metrics from '@overleaf/metrics'
async function getMetric(matcher) {
const { body } = await request.promises.request('/metrics')
const found = body.split('\n').find(matcher)
if (!found) return 0
return parseInt(found.split(' ')[1], 0)
}
/* sets all metrics to zero
https://github.com/siimon/prom-client?tab=readme-ov-file#resetting-metrics
*/
function resetMetrics() {
metrics.register.resetMetrics()
}
export default {
getMetric: callbackify(getMetric),
resetMetrics,
promises: {
getMetric,
},
}

View File

@@ -0,0 +1,57 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import Async from 'async'
import UserSessionsRedis from '../../../../app/src/Features/User/UserSessionsRedis.js'
// rclient = redis.createClient(Settings.redis.web)
const rclient = UserSessionsRedis.client()
export default {
getUserSessions(user, callback) {
if (callback == null) {
callback = function () {}
}
return rclient.smembers(
UserSessionsRedis.sessionSetKey(user),
(err, result) => callback(err, result)
)
},
clearUserSessions(user, callback) {
if (callback == null) {
callback = function () {}
}
const sessionSetKey = UserSessionsRedis.sessionSetKey(user)
return rclient.smembers(sessionSetKey, (err, sessionKeys) => {
if (err) {
return callback(err)
}
if (sessionKeys.length === 0) {
return callback(null)
}
const actions = sessionKeys.map(k => cb => rclient.del(k, err => cb(err)))
return Async.series(actions, (err, results) =>
rclient.srem(sessionSetKey, sessionKeys, err => {
if (err) {
return callback(err)
}
return callback(null)
})
)
})
},
}

View File

@@ -0,0 +1,24 @@
// TODO: This file was created by bulk-decaffeinate.
// Sanity-check the conversion and remove this comment.
const BASE_URL = `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
const request = require('request').defaults({
baseUrl: BASE_URL,
followRedirect: false,
})
module.exports = request
module.exports.BASE_URL = BASE_URL
module.exports.promises = {
request: function (options) {
return new Promise((resolve, reject) => {
request(options, (err, response) => {
if (err) {
reject(err)
} else {
resolve(response)
}
})
})
},
}

View File

@@ -0,0 +1,11 @@
export function filterOutput(line) {
return (
!!line &&
!line.startsWith('Using settings from ') &&
!line.startsWith('Using default settings from ') &&
!line.startsWith('CoffeeScript settings file') &&
!line.includes('mongoose default connection open')
)
}
export default { filterOutput }

View File

@@ -0,0 +1,193 @@
import OError from '@overleaf/o-error'
import express from 'express'
import bodyParser from 'body-parser'
/**
* Abstract class for running a mock API via Express. Handles setting up of
* the server on a specific port, and provides an overridable method to
* initialise routes.
*
* Mocks are singletons, and must be initialized with the `initialize` method.
* Instance objects are available via the `instance()` method.
*
* You must override 'reset' and 'applyRoutes' when subclassing this
*
* Wraps the express app's http verb methods for convenience
*
* @hideconstructor
* @member {number} port - the port for the http server
* @member app - the Express application
*/
class AbstractMockApi {
/**
* Create a new API. No not call directly - use the `initialize` method
*
* @param {number} port - The TCP port to start the API on
* @param {object} options - An optional hash of options to modify the behaviour of the mock
* @param {boolean} options.debug - When true, print http requests and responses to stdout
* Set this to 'true' from the constructor of your derived class
*/
constructor(port, { debug } = {}) {
if (!this.constructor._fromInit) {
throw new OError(
'do not create this class directly - use the initialize method',
{ className: this.constructor.name }
)
}
if (this.constructor._obj) {
throw new OError('mock already initialized', {
className: this.constructor._obj.constructor.name,
port: this.port,
})
}
if (this.constructor === AbstractMockApi) {
throw new OError(
'Do not construct AbstractMockApi directly - use a subclass'
)
}
this.debug = debug
this.port = port
this.app = express()
this.app.use(bodyParser.json())
this.app.use(bodyParser.urlencoded({ extended: true }))
}
/**
* Apply debugging routes to print out API activity to stdout
*/
applyDebugRoutes() {
if (!this.debug) return
this.app.use((req, res, next) => {
const { method, path, query, params, body } = req
// eslint-disable-next-line no-console
console.log(`${this.constructor.name} REQUEST`, {
method,
path,
query,
params,
body,
})
const oldEnd = res.end
const oldJson = res.json
res.json = (...args) => {
// eslint-disable-next-line no-console
console.log(`${this.constructor.name} RESPONSE JSON`, args[0])
oldJson.call(res, ...args)
}
res.end = (...args) => {
// eslint-disable-next-line no-console
console.log(`${this.constructor.name} STATUS`, res.statusCode)
if (res.statusCode >= 500) {
// eslint-disable-next-line no-console
console.log('ERROR RESPONSE:', args)
}
oldEnd.call(res, ...args)
}
next()
})
}
/**
* Overridable method to add routes - should be overridden in derived classes
* @abstract
*/
applyRoutes() {
throw new OError(
'AbstractMockApi base class implementation should not be called'
)
}
/**
* Resets member data and restores the API to a clean state for the next test run
* - may be overridden in derived classes
*/
reset() {}
/**
* Applies mocha hooks to start and stop the API at the beginning/end of
* the test suite, and reset before each test run
*
* @param {number} port - The TCP port to start the API on
* @param {object} options - An optional hash of options to modify the behaviour of the mock
* @param {boolean} options.debug - When true, print http requests and responses to stdout
* Set this to 'true' from the constructor of your derived class
*/
static initialize(port, { debug } = {}) {
// `this` refers to the derived class
this._fromInit = true
this._obj = new this(port, { debug })
this._obj.applyDebugRoutes()
this._obj.applyRoutes()
/* eslint-disable mocha/no-mocha-arrows */
const name = this.constructor.name
before(`starting mock ${name}`, () => this._obj.start())
after(`stopping mock ${name}`, () => this._obj.stop())
beforeEach(`resetting mock ${name}`, () => this._obj.reset())
}
/**
* Starts the API on the configured port
*
* @return {Promise<void>}
*/
async start() {
return new Promise((resolve, reject) => {
if (this.debug) {
// eslint-disable-next-line no-console
console.log('Starting mock on port', this.constructor.name, this.port)
}
this.server = this.app
.listen(this.port, err => {
if (err) {
return reject(err)
}
resolve()
})
.on('error', error => {
// eslint-disable-next-line no-console
console.error(
'error starting mock:',
this.constructor.name,
error.message
)
process.exit(1)
})
})
}
/**
* Returns the constructed object
*
* @return {AbstractMockApi}
*/
static instance() {
return this._obj
}
/**
* Shuts down the API and waits for it to stop listening
*
* @return {Promise<void>}
*/
async stop() {
if (!this.server) return
return new Promise((resolve, reject) => {
if (this.debug) {
// eslint-disable-next-line no-console
console.log('Stopping mock', this.constructor.name)
}
this.server.close(err => {
delete this.server
if (err) {
return reject(err)
}
resolve()
})
})
}
}
export default AbstractMockApi

View File

@@ -0,0 +1,41 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockAnalyticsApi extends AbstractMockApi {
reset() {
this.updates = {}
}
applyRoutes() {
this.app.get('/graphs/:graph', (req, res) => {
return res.json({})
})
this.app.get('/recentInstitutionActivity', (req, res) => {
res.json({
institutionId: 123,
day: {
projects: 0,
users: 0,
},
week: {
projects: 0,
users: 0,
},
month: {
projects: 1,
users: 2,
},
})
})
}
}
export default MockAnalyticsApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockAnalyticsApi
* @static
* @returns {MockAnalyticsApi}
*/

View File

@@ -0,0 +1,52 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockChatApi extends AbstractMockApi {
reset() {
this.projects = {}
}
getGlobalMessages(req, res) {
res.json(this.projects[req.params.project_id] || [])
}
sendGlobalMessage(req, res) {
const projectId = req.params.project_id
const message = {
id: Math.random().toString(),
content: req.body.content,
timestamp: Date.now(),
user_id: req.body.user_id,
}
this.projects[projectId] = this.projects[projectId] || []
this.projects[projectId].push(message)
res.json(Object.assign({ room_id: projectId }, message))
}
destroyProject(req, res) {
const projectId = req.params.project_id
delete this.projects[projectId]
res.sendStatus(204)
}
applyRoutes() {
this.app.get('/project/:project_id/messages', (req, res) =>
this.getGlobalMessages(req, res)
)
this.app.post('/project/:project_id/messages', (req, res) =>
this.sendGlobalMessage(req, res)
)
this.app.delete('/project/:project_id', (req, res) =>
this.destroyProject(req, res)
)
}
}
export default MockChatApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockChatApi
* @static
* @returns {MockChatApi}
*/

View File

@@ -0,0 +1,70 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js'
class MockClsiApi extends AbstractMockApi {
static compile(req, res) {
res.json({
compile: {
status: 'success',
error: null,
outputFiles: [
{
url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/project.pdf`,
path: 'project.pdf',
type: 'pdf',
build: 1234,
},
{
url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/project.log`,
path: 'project.log',
type: 'log',
build: 1234,
},
],
},
})
}
applyRoutes() {
this.app.post('/project/:project_id/compile', MockClsiApi.compile)
this.app.post(
'/project/:project_id/user/:user_id/compile',
MockClsiApi.compile
)
this.app.get(
'/project/:project_id/build/:build_id/output/*',
(req, res) => {
const filename = req.params[0]
if (filename === 'project.pdf') {
plainTextResponse(res, 'mock-pdf')
} else if (filename === 'project.log') {
plainTextResponse(res, 'mock-log')
} else {
res.sendStatus(404)
}
}
)
this.app.get(
'/project/:project_id/user/:user_id/build/:build_id/output/:output_path',
(req, res) => {
plainTextResponse(res, 'hello')
}
)
this.app.get('/project/:project_id/status', (req, res) => {
res.sendStatus(200)
})
}
}
export default MockClsiApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockClsiApi
* @static
* @returns {MockClsiApi}
*/

View File

@@ -0,0 +1,67 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockDocUpdaterApi extends AbstractMockApi {
reset() {
this.updates = {}
}
getProjectStructureUpdates(projectId) {
return this.updates[projectId] || { updates: [] }
}
addProjectStructureUpdates(projectId, userId, updates, version) {
if (!this.updates[projectId]) {
this.updates[projectId] = { updates: [] }
}
for (const update of updates) {
update.userId = userId
this.updates[projectId].updates.push(update)
}
this.updates[projectId].version = version
}
applyRoutes() {
this.app.post('/project/:projectId/flush', (req, res) => {
res.sendStatus(204)
})
this.app.post('/project/:projectId', (req, res) => {
const { projectId } = req.params
const { userId, updates, version } = req.body
this.addProjectStructureUpdates(projectId, userId, updates, version)
res.sendStatus(200)
})
this.app.post('/project/:projectId/doc/:doc_id', (req, res) => {
res.sendStatus(204)
})
this.app.delete('/project/:projectId', (req, res) => {
res.sendStatus(204)
})
this.app.post('/project/:projectId/doc/:doc_id/flush', (req, res) => {
res.sendStatus(204)
})
this.app.delete('/project/:projectId/doc/:doc_id', (req, res) => {
res.sendStatus(204)
})
this.app.post('/project/:projectId/history/resync', (req, res) => {
res.sendStatus(204)
})
}
}
export default MockDocUpdaterApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockDocUpdaterApi
* @static
* @returns {MockDocUpdaterApi}
*/

View File

@@ -0,0 +1,108 @@
import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js'
import AbstractMockApi from './AbstractMockApi.mjs'
class MockDocstoreApi extends AbstractMockApi {
reset() {
this.docs = {}
}
createLegacyDeletedDoc(projectId, docId) {
if (!this.docs[projectId]) {
this.docs[projectId] = {}
}
this.docs[projectId][docId] = {
lines: [],
version: 1,
ranges: {},
deleted: true,
}
}
getDeletedDocs(projectId) {
return Object.entries(this.docs[projectId] || {})
.filter(([_, doc]) => doc.deleted)
.map(([docId, doc]) => {
return { _id: docId, name: doc.name }
})
}
applyRoutes() {
this.app.post('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
const { lines, version, ranges } = req.body
if (this.docs[projectId] == null) {
this.docs[projectId] = {}
}
if (this.docs[projectId][docId] == null) {
this.docs[projectId][docId] = {}
}
const { version: oldVersion, deleted } = this.docs[projectId][docId]
this.docs[projectId][docId] = { lines, version, ranges, deleted }
if (this.docs[projectId][docId].rev == null) {
this.docs[projectId][docId].rev = 0
}
this.docs[projectId][docId].rev += 1
this.docs[projectId][docId]._id = docId
res.json({
modified: oldVersion !== version,
rev: this.docs[projectId][docId].rev,
})
})
this.app.get('/project/:projectId/doc', (req, res) => {
res.json(Object.values(this.docs[req.params.projectId] || {}))
})
this.app.get('/project/:projectId/doc-deleted', (req, res) => {
res.json(this.getDeletedDocs(req.params.projectId))
})
this.app.get('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
const doc = this.docs[projectId][docId]
if (!doc || (doc.deleted && !req.query.include_deleted)) {
res.sendStatus(404)
} else {
res.json(doc)
}
})
this.app.get('/project/:projectId/doc/:docId/deleted', (req, res) => {
const { projectId, docId } = req.params
if (!this.docs[projectId] || !this.docs[projectId][docId]) {
res.sendStatus(404)
} else {
res.json({ deleted: Boolean(this.docs[projectId][docId].deleted) })
}
})
this.app.patch('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
if (!this.docs[projectId]) {
res.sendStatus(404)
} else if (!this.docs[projectId][docId]) {
res.sendStatus(404)
} else {
Object.assign(this.docs[projectId][docId], req.body)
res.sendStatus(204)
}
})
this.app.post('/project/:projectId/destroy', async (req, res) => {
const { projectId } = req.params
delete this.docs[projectId]
await db.docs.deleteMany({ project_id: new ObjectId(projectId) })
res.sendStatus(204)
})
}
}
export default MockDocstoreApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockDocstoreApi
* @static
* @returns {MockDocstoreApi}
*/

View File

@@ -0,0 +1,81 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockFilestoreApi extends AbstractMockApi {
reset() {
this.files = {}
}
applyRoutes() {
this.app.post('/project/:projectId/file/:fileId', (req, res) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const content = Buffer.concat(chunks)
const { projectId, fileId } = req.params
if (!this.files[projectId]) {
this.files[projectId] = {}
}
this.files[projectId][fileId] = content
res.sendStatus(200)
})
})
this.app.head('/project/:projectId/file/:fileId', (req, res) => {
const { projectId, fileId } = req.params
const content = this.files[projectId]?.[fileId]
if (!content) return res.status(404).end()
res.set('Content-Length', content.byteLength)
res.status(200).end()
})
this.app.get('/project/:projectId/file/:fileId', (req, res) => {
const { projectId, fileId } = req.params
const content = this.files[projectId]?.[fileId]
if (!content) return res.status(404).end()
res.status(200).end(content)
})
// handle file copying
this.app.put('/project/:projectId/file/:fileId', (req, res) => {
const { projectId, fileId } = req.params
const { source } = req.body
const content =
this.files[source.project_id] &&
this.files[source.project_id][source.file_id]
if (!content) {
res.sendStatus(500)
} else {
if (!this.files[projectId]) {
this.files[projectId] = {}
}
this.files[projectId][fileId] = content
res.sendStatus(200)
}
})
this.app.delete('/project/:projectId', (req, res) => {
const { projectId } = req.params
delete this.files[projectId]
res.sendStatus(204)
})
}
getFile(projectId, fileId) {
return (
this.files[projectId] &&
this.files[projectId][fileId] &&
this.files[projectId][fileId].toString()
)
}
}
export default MockFilestoreApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockFilestoreApi
* @static
* @returns {MockFilestoreApi}
*/

View File

@@ -0,0 +1,21 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockGitBridgeApi extends AbstractMockApi {
reset() {
this.projects = {}
}
applyRoutes() {
this.app.delete('/api/projects/:projectId', (req, res) => {
this.deleteProject(req, res)
})
}
deleteProject(req, res) {
const projectId = req.params.projectId
delete this.projects[projectId]
res.sendStatus(204)
}
}
export default MockGitBridgeApi

View File

@@ -0,0 +1,46 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockGoogleOauthApi extends AbstractMockApi {
reset() {
this.profiles = {}
this.tokens = {}
}
addProfile(profile, token, authorizationCode) {
this.profiles[token] = {
picture: 'https://example.com/picture.jpg',
email_verified: true,
locale: 'en-GB',
...profile,
}
this.tokens[authorizationCode] = token
}
applyRoutes() {
this.app.post('/oauth/token', (req, res, next) => {
if (!this.tokens[req.body.code]) {
return res.sendStatus(400)
}
res.json({
access_token: this.tokens[req.body.code],
})
})
this.app.get('/oauth2/v3/userinfo', (req, res, next) => {
if (!this.profiles[req.query.access_token]) {
return res.sendStatus(400)
}
res.json(this.profiles[req.query.access_token])
})
}
}
export default MockGoogleOauthApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockGoogleOauthApi
* @static
* @returns {MockGoogleOauthApi}
*/

View File

@@ -0,0 +1,51 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js'
class MockHaveIBeenPwnedApi extends AbstractMockApi {
reset() {
this.seenPasswords = {}
}
addPasswordByHash(hash) {
this.seenPasswords[hash] |= 0
this.seenPasswords[hash]++
}
getPasswordsByRange(prefix) {
if (prefix.length !== 5) {
throw new Error('prefix must be of length 5')
}
const matches = [
// padding
'274CCEF6AB4DFAAF86599792FA9C3FE4689:42',
'29780E39FF6511C0FC227744B2817D122F4:1337',
]
for (const [hash, score] of Object.entries(this.seenPasswords)) {
if (hash.startsWith(prefix)) {
matches.push(hash.slice(5) + ':' + score)
}
}
return matches.join('\r\n')
}
applyRoutes() {
this.app.get('/range/:prefix', (req, res) => {
const { prefix } = req.params
if (prefix === 'C8893') {
plainTextResponse(res, '74D74EFD7B158D2ADD283D67FF3E53B55D7:broken')
} else {
plainTextResponse(res, this.getPasswordsByRange(prefix))
}
})
}
}
export default MockHaveIBeenPwnedApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockHaveIBeenPwnedApi
* @static
* @returns {MockHaveIBeenPwnedApi}
*/

View File

@@ -0,0 +1,37 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockHistoryBackupDeletionApi extends AbstractMockApi {
reset() {
this.projects = {}
}
prepareProject(projectId, status) {
this.projects[projectId.toString()] = status
}
deleteProject(req, res) {
const projectId = req.params.project_id
const status = this.projects[projectId]
if (status === 422) {
return res.sendStatus(422)
}
delete this.projects[projectId]
res.sendStatus(204)
}
applyRoutes() {
this.app.delete('/project/:project_id/backup', (req, res) =>
this.deleteProject(req, res)
)
}
}
export default MockHistoryBackupDeletionApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockHistoryBackupDeletionApi
* @static
* @returns {MockHistoryBackupDeletionApi}
*/

View File

@@ -0,0 +1,22 @@
import AbstractMockApi from './AbstractMockApi.mjs'
// Currently there is nothing implemented here as we have no acceptance tests
// for the notifications API. This does however stop errors appearing in the
// output when the acceptance tests try to connect.
class MockNotificationsApi extends AbstractMockApi {
applyRoutes() {
this.app.get('/*', (req, res) => res.json([]))
this.app.post('/*', (req, res) => res.sendStatus(200))
}
}
export default MockNotificationsApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockNotificationsApi
* @static
* @returns {MockNotificationsApi}
*/

View File

@@ -0,0 +1,159 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import _ from 'lodash'
import mongodb from 'mongodb-legacy'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js'
const { ObjectId } = mongodb
class MockProjectHistoryApi extends AbstractMockApi {
reset() {
this.docs = {}
this.oldFiles = {}
this.projectVersions = {}
this.labels = {}
this.projectSnapshots = {}
this.projectHistoryId = 1
}
addOldFile(projectId, version, pathname, content) {
this.oldFiles[`${projectId}:${version}:${pathname}`] = content
}
addProjectSnapshot(projectId, version, snapshot) {
this.projectSnapshots[`${projectId}:${version}`] = snapshot
}
setProjectVersion(projectId, version) {
this.projectVersions[projectId] = { version }
}
setProjectVersionInfo(projectId, versionInfo) {
this.projectVersions[projectId] = versionInfo
}
addLabel(projectId, label) {
if (label.id == null) {
label.id = new ObjectId().toString()
}
if (this.labels[projectId] == null) {
this.labels[projectId] = {}
}
this.labels[projectId][label.id] = label
}
deleteLabel(projectId, labelId) {
delete this.labels[projectId][labelId]
}
getLabels(projectId) {
if (this.labels[projectId] == null) {
return null
}
return _.values(this.labels[projectId])
}
applyRoutes() {
this.app.post('/project', (req, res) => {
res.json({ project: { id: this.projectHistoryId++ } })
})
this.app.delete('/project/:projectId', (req, res) => {
res.sendStatus(204)
})
this.app.get(
'/project/:projectId/version/:version/:pathname',
(req, res) => {
const { projectId, version, pathname } = req.params
const key = `${projectId}:${version}:${pathname}`
if (this.oldFiles[key] != null) {
plainTextResponse(res, this.oldFiles[key])
} else {
res.sendStatus(404)
}
}
)
this.app.get('/project/:projectId/version/:version', (req, res) => {
const { projectId, version } = req.params
const key = `${projectId}:${version}`
if (this.projectSnapshots[key] != null) {
res.json(this.projectSnapshots[key])
} else {
res.sendStatus(404)
}
})
this.app.get('/project/:projectId/version', (req, res) => {
const { projectId } = req.params
if (this.projectVersions[projectId] != null) {
res.json(this.projectVersions[projectId])
} else {
res.sendStatus(404)
}
})
this.app.get('/project/:projectId/labels', (req, res) => {
const { projectId } = req.params
const labels = this.getLabels(projectId)
if (labels != null) {
res.json(labels)
} else {
res.sendStatus(404)
}
})
this.app.post('/project/:projectId/labels', (req, res) => {
const { projectId } = req.params
const { comment, version } = req.body
const labelId = new ObjectId().toString()
this.addLabel(projectId, { id: labelId, comment, version })
res.json({ label_id: labelId, comment, version })
})
this.app.delete(
'/project/:projectId/user/:user_id/labels/:labelId',
(req, res) => {
const { projectId, labelId } = req.params
const label =
this.labels[projectId] != null
? this.labels[projectId][labelId]
: undefined
if (label != null) {
this.deleteLabel(projectId, labelId)
res.sendStatus(204)
} else {
res.sendStatus(404)
}
}
)
this.app.delete('/project/:projectId/labels/:labelId', (req, res) => {
const { projectId, labelId } = req.params
const label =
this.labels[projectId] != null
? this.labels[projectId][labelId]
: undefined
if (label != null) {
this.deleteLabel(projectId, labelId)
res.sendStatus(204)
} else {
res.sendStatus(404)
}
})
this.app.post('/project/:projectId/flush', (req, res) => {
res.sendStatus(200)
})
}
}
export default MockProjectHistoryApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockProjectHistoryApi
* @static
* @returns {MockProjectHistoryApi}
*/

View File

@@ -0,0 +1,21 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockReCaptchaApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/recaptcha/api/siteverify', (req, res) => {
res.json({
success: req.body.response === 'valid',
})
})
}
}
export default MockReCaptchaApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockReCaptchaApi
* @static
* @returns {MockReCaptchaApi}
*/

View File

@@ -0,0 +1,143 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.js'
import { xmlResponse } from '../../../../app/src/infrastructure/Response.js'
class MockRecurlyApi extends AbstractMockApi {
reset() {
this.mockSubscriptions = []
this.redemptions = {}
this.coupons = {}
}
addMockSubscription(recurlySubscription) {
this.mockSubscriptions.push(recurlySubscription)
}
getMockSubscriptionByAccountId(accountId) {
return this.mockSubscriptions.find(
mockSubscription => mockSubscription.account.id === accountId
)
}
getMockSubscriptionById(uuid) {
return this.mockSubscriptions.find(
mockSubscription => mockSubscription.uuid === uuid
)
}
applyRoutes() {
this.app.get('/subscriptions/:id', (req, res) => {
const subscription = this.getMockSubscriptionById(req.params.id)
if (!subscription) {
res.sendStatus(404)
} else {
xmlResponse(
res,
`\
<subscription>
<plan><plan_code>${subscription.planCode}</plan_code></plan>
<currency>${subscription.currency}</currency>
<state>${subscription.state}</state>
<tax_in_cents type="integer">${subscription.tax_in_cents}</tax_in_cents>
<tax_rate type="float">${subscription.tax_rate}</tax_rate>
<current_period_ends_at type="datetime">${subscription.current_period_ends_at}</current_period_ends_at>
<unit_amount_in_cents type="integer">${subscription.unit_amount_in_cents}</unit_amount_in_cents>
<account href="accounts/${subscription.account.id}" />
<trial_ends_at type="datetime">${subscription.trial_ends_at}</trial_ends_at>
</subscription>\
`
)
}
})
this.app.get('/accounts/:id', (req, res) => {
const subscription = this.getMockSubscriptionByAccountId(req.params.id)
if (!subscription) {
res.sendStatus(404)
} else {
xmlResponse(
res,
`\
<account>
<account_code>${req.params.id}</account_code>
<hosted_login_token>${subscription.account.hosted_login_token}</hosted_login_token>
<email>${subscription.account.email}</email>
</account>\
`
)
}
})
this.app.put(
'/accounts/:id',
SubscriptionController.recurlyNotificationParser, // required to parse XML requests
(req, res) => {
const subscription = this.getMockSubscriptionByAccountId(req.params.id)
if (!subscription) {
res.sendStatus(404)
} else {
Object.assign(subscription.account, req.body.account)
xmlResponse(
res,
`\
<account>
<account_code>${req.params.id}</account_code>
<email>${subscription.account.email}</email>
</account>\
`
)
}
}
)
this.app.get('/coupons/:code', (req, res) => {
const coupon = this.coupons[req.params.code]
if (!coupon) {
res.sendStatus(404)
} else {
xmlResponse(
res,
`\
<coupon>
<coupon_code>${req.params.code}</coupon_code>
<name>${coupon.name || ''}</name>
<description>${coupon.description || ''}</description>
</coupon>\
`
)
}
})
this.app.get('/accounts/:id/redemptions', (req, res) => {
const redemptions = this.redemptions[req.params.id] || []
let redemptionsListXml = ''
for (const redemption of Array.from(redemptions)) {
redemptionsListXml += `\
<redemption>
<state>${redemption.state}</state>
<coupon_code>${redemption.coupon_code}</coupon_code>
</redemption>\
`
}
xmlResponse(
res,
`\
<redemptions type="array">
${redemptionsListXml}
</redemptions>\
`
)
})
}
}
export default MockRecurlyApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockRecurlyApi
* @static
* @returns {MockRecurlyApi}
*/

View File

@@ -0,0 +1,55 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockSpellingApi extends AbstractMockApi {
reset() {
this.words = {}
}
applyRoutes() {
this.app.get('/user/:userId', (req, res) => {
const { userId } = req.params
const words = this.words[userId] || []
res.json(words)
})
this.app.delete('/user/:userId', (req, res) => {
const { userId } = req.params
this.words.delete(userId)
res.sendStatus(200)
})
this.app.post('/user/:userId/learn', (req, res) => {
const word = req.body.word
const { userId } = req.params
if (word) {
this.words[userId] = this.words[userId] || []
if (!this.words[userId].includes(word)) {
this.words[userId].push(word)
}
}
res.sendStatus(200)
})
this.app.post('/user/:userId/unlearn', (req, res) => {
const word = req.body.word
const { userId } = req.params
if (word && this.words[userId]) {
const wordIndex = this.words[userId].indexOf(word)
if (wordIndex !== -1) {
this.words[userId].splice(wordIndex, 1)
}
}
res.sendStatus(200)
})
}
}
export default MockSpellingApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockSpellingApi
* @static
* @returns {MockSpellingApi}
*/

View File

@@ -0,0 +1,30 @@
import AbstractMockApi from './AbstractMockApi.mjs'
class MockThirdPartyDataStoreApi extends AbstractMockApi {
reset() {}
deleteUser(req, res) {
res.sendStatus(200)
}
unlinkUser(req, res) {
res.sendStatus(200)
}
applyRoutes() {
this.app.delete('/user/:user_id', (req, res) => this.deleteUser(req, res))
this.app.delete('/user/:user_id/dropbox', (req, res) =>
this.unlinkUser(req, res)
)
}
}
export default MockThirdPartyDataStoreApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockThirdPartyDataStoreApi
* @static
* @returns {MockThirdPartyDataStoreApi}
*/

View File

@@ -0,0 +1,473 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import moment from 'moment'
import sinon from 'sinon'
class MockV1Api extends AbstractMockApi {
reset() {
this.affiliations = []
this.allInstitutionDomains = new Set()
this.blocklistedDomains = []
this.brand_variations = {}
this.brands = {}
this.doc_exported = {}
this.docInfo = {}
this.existingEmails = []
this.exportId = null
this.exportParams = null
this.institutionDomains = {}
this.institutionId = 1000
this.institutions = {}
this.syncUserFeatures = sinon.stub()
this.templates = {}
this.updateEmail = sinon.stub()
this.users = {}
this.v1Id = 1000
this.validation_clients = {}
}
nextInstitutionId() {
return this.institutionId++
}
nextV1Id() {
return this.v1Id++
}
setUser(id, user) {
this.users[id] = user
}
getDocInfo(token) {
return this.docInfo[token] || null
}
setDocInfo(token, info) {
this.docInfo[token] = info
}
setExportId(id) {
this.exportId = id
}
getLastExportParams() {
return this.exportParams
}
clearExportParams() {
this.exportParams = null
}
createInstitution(options = {}) {
const id = options.university_id || this.nextInstitutionId()
options.id = id // include ID so that it is included in APIs
this.institutions[id] = { ...options }
if (options && options.hostname) {
this.addInstitutionDomain(id, options.hostname, {
confirmed: options.confirmed,
})
}
return id
}
addInstitutionDomain(institutionId, domain, options = {}) {
if (this.allInstitutionDomains.has(domain)) return
if (!this.institutionDomains[institutionId]) {
this.institutionDomains[institutionId] = {}
}
this.institutionDomains[institutionId][domain] = options
this.allInstitutionDomains.add(domain)
}
updateInstitution(id, options) {
Object.assign(this.institutions[id], options)
}
updateInstitutionDomain(id, domain, options = {}) {
if (!this.institutionDomains[id] || !this.institutionDomains[id][domain])
return
this.institutionDomains[id][domain] = Object.assign(
{},
this.institutionDomains[id][domain],
options
)
}
addAffiliation(userId, email, entitlement, confirmedAt) {
let newAffiliation = true
const institution = {}
if (!email) return
if (!this.affiliations[userId]) this.affiliations[userId] = []
if (
this.affiliations[userId].find(affiliationData => {
return affiliationData.email === email
})
)
newAffiliation = false
if (newAffiliation) {
const domain = email.split('@').pop()
if (this.blocklistedDomains.indexOf(domain.replace('.com', '')) !== -1) {
return
}
if (this.allInstitutionDomains.has(domain)) {
for (const [institutionId, domainData] of Object.entries(
this.institutionDomains
)) {
if (domainData[domain]) {
institution.id = institutionId
}
}
}
if (institution.id) {
this.affiliations[userId].push({ email, institution })
}
}
if (entitlement !== undefined) {
this.affiliations[userId].forEach(affiliation => {
if (affiliation.email === email) {
affiliation.cached_entitlement = entitlement
}
})
}
if (confirmedAt) {
this.affiliations[userId].forEach(affiliation => {
if (affiliation.email === email) {
if (!affiliation.cached_confirmed_at) {
affiliation.cached_confirmed_at = confirmedAt
}
affiliation.cached_reconfirmed_at = confirmedAt
}
})
}
}
setDocExported(token, info) {
this.doc_exported[token] = info
}
setTemplates(templates) {
this.templates = templates
}
applyRoutes() {
this.app.get('/api/v1/overleaf/users/:v1_user_id/plan_code', (req, res) => {
const user = this.users[req.params.v1_user_id]
if (user) {
res.json(user)
} else {
res.sendStatus(404)
}
})
this.app.get(
'/api/v1/overleaf/users/:v1_user_id/subscriptions',
(req, res) => {
const user = this.users[req.params.v1_user_id]
if (user && user.subscription) {
res.json(user.subscription)
} else {
res.sendStatus(404)
}
}
)
this.app.get(
'/api/v1/overleaf/users/:v1_user_id/subscription_status',
(req, res) => {
const user = this.users[req.params.v1_user_id]
if (user && user.subscription_status) {
res.json(user.subscription_status)
} else {
res.sendStatus(404)
}
}
)
this.app.delete(
'/api/v1/overleaf/users/:v1_user_id/subscription',
(req, res) => {
const user = this.users[req.params.v1_user_id]
if (user) {
user.canceled = true
res.sendStatus(200)
} else {
res.sendStatus(404)
}
}
)
this.app.post('/api/v1/overleaf/users/:v1_user_id/sync', (req, res) => {
this.syncUserFeatures(req.params.v1_user_id)
res.sendStatus(200)
})
this.app.post('/api/v1/overleaf/exports', (req, res) => {
this.exportParams = Object.assign({}, req.body)
res.json({ exportId: this.exportId })
})
this.app.get('/api/v2/users/:userId/affiliations', (req, res) => {
if (!this.affiliations[req.params.userId]) return res.json([])
const affiliations = this.affiliations[req.params.userId].map(
affiliation => {
const institutionId = affiliation.institution.id
const domain = affiliation.email.split('@').pop()
const domainData =
this.institutionDomains[institutionId][domain] || {}
const institutionData = this.institutions[institutionId] || {}
affiliation.institution = {
id: institutionId,
name: institutionData.name,
commonsAccount: institutionData.commonsAccount,
isUniversity: !institutionData.institution,
ssoBeta: institutionData.sso_beta || false,
ssoEnabled: institutionData.sso_enabled || false,
maxConfirmationMonths:
institutionData.maxConfirmationMonths || null,
}
affiliation.institution.confirmed = !!domainData.confirmed
affiliation.licence = 'free'
if (
institutionData.commonsAccount &&
(!institutionData.sso_enabled ||
(institutionData.sso_enabled &&
affiliation.cached_entitlement === true))
) {
affiliation.licence = 'pro_plus'
}
if (
institutionData.maxConfirmationMonths &&
affiliation.cached_reconfirmed_at
) {
const lastDayToReconfirm = moment(
affiliation.cached_reconfirmed_at
).add(institutionData.maxConfirmationMonths, 'months')
affiliation.last_day_to_reconfirm = lastDayToReconfirm.toDate()
affiliation.past_reconfirm_date = lastDayToReconfirm.isBefore()
}
return affiliation
}
)
res.json(affiliations)
})
this.app.post('/api/v2/users/:userId/affiliations', (req, res) => {
this.addAffiliation(
req.params.userId,
req.body.email,
req.body.entitlement,
req.body.confirmedAt
)
res.sendStatus(201)
})
this.app.delete('/api/v2/users/:userId/affiliations', (req, res) => {
res.sendStatus(201)
})
this.app.delete('/api/v2/users/:userId/affiliations/:email', (req, res) => {
res.sendStatus(204)
})
this.app.post(
'/api/v2/institutions/reconfirmation_lapsed_processed',
(req, res) => {
res.sendStatus(200)
}
)
this.app.get(
'/api/v2/institutions/need_reconfirmation_lapsed_processed',
(req, res) => {
const usersWithAffiliations = []
Object.keys(this.affiliations).forEach(userId => {
if (this.affiliations[userId].length > 0) {
usersWithAffiliations.push(userId)
}
})
res.json({ data: { users: usersWithAffiliations } })
}
)
this.app.get('/api/v2/brands/:slug', (req, res) => {
let brand
if ((brand = this.brands[req.params.slug])) {
res.json(brand)
} else {
res.sendStatus(404)
}
})
this.app.get('/universities/list', (req, res) => {
const response = []
const university1 = {
id: 1337,
name: 'Institution 1337',
country_code: 'en',
departments: [],
}
const university2 = {
id: 243,
name: 'Institution 243',
country_code: 'en',
departments: [],
}
if (req.query.country_code === 'en') {
response.push(university1)
}
if (req.query.search === 'Institution') {
response.push(university1)
if (req.query.max_results !== '1') {
response.push(university2)
}
}
res.json(response)
})
this.app.get('/universities/list/:id', (req, res) =>
res.json({
id: parseInt(req.params.id),
name: `Institution ${req.params.id}`,
})
)
this.app.get('/university/domains', (req, res) => {
if (req.query.hostname === 'overleaf.com') {
res.json([
{
id: 42,
hostname: 'overleaf.com',
department: 'Overleaf',
confirmed: true,
university: {
id: 1337,
name: 'Institution 1337',
departments: [],
ssoBeta: false,
ssoEnabled: false,
},
},
])
} else {
res.json([])
}
})
this.app.put('/api/v1/overleaf/users/:id/email', (req, res) => {
const { email } = req.body && req.body.user
if (this.existingEmails.includes(email)) {
res.sendStatus(409)
} else {
this.updateEmail(parseInt(req.params.id), email)
res.sendStatus(200)
}
})
this.app.post('/api/v1/overleaf/login', (req, res) => {
for (const id in this.users) {
const user = this.users[id]
if (
user &&
user.email === req.body.email &&
user.password === req.body.password
) {
return res.json({
email: user.email,
valid: true,
user_profile: user.profile,
})
}
}
res.status(403).json({
email: req.body.email,
valid: false,
})
})
this.app.get('/api/v2/partners/:partner/conversions/:id', (req, res) => {
const partner = this.validation_clients[req.params.partner]
const conversion =
partner && partner.conversions && partner.conversions[req.params.id]
if (conversion) {
res.status(200).json({
input_file_uri: conversion,
brand_variation_id: partner.brand_variation_id,
})
} else {
res.status(404).json({})
}
})
this.app.get('/api/v2/brand_variations/:id', (req, res) => {
const variation = this.brand_variations[req.params.id]
if (variation) {
res.status(200).json(variation)
} else {
res.status(404).json({})
}
})
this.app.get('/api/v1/overleaf/docs/:token/is_published', (req, res) => {
return res.json({ allow: true })
})
this.app.get(
'/api/v1/overleaf/users/:user_id/docs/:token/info',
(req, res) => {
const info = this.getDocInfo(req.params.token) || {
exists: false,
exported: false,
}
res.json(info)
}
)
this.app.get('/api/v1/overleaf/docs/:token/info', (req, res) => {
const info = this.getDocInfo(req.params.token) || {
exists: false,
exported: false,
}
res.json(info)
})
this.app.get(
'/api/v1/overleaf/docs/read_token/:token/exists',
(req, res) => {
res.json({ exists: false })
}
)
this.app.get('/api/v2/templates/:templateId', (req, res) => {
const template = this.templates[req.params.templateId]
if (!template) {
return res.sendStatus(404)
}
res.json(template)
})
}
}
export default MockV1Api
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockV1Api
* @static
* @returns {MockV1Api}
*/

View File

@@ -0,0 +1,131 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { EventEmitter } from 'node:events'
import {
zipAttachment,
prepareZipAttachment,
} from '../../../../app/src/infrastructure/Response.js'
import Joi from 'joi'
class MockV1HistoryApi extends AbstractMockApi {
reset() {
this.fakeZipCall = 0
this.requestedZipPacks = 0
this.sentChunks = 0
this.events = new EventEmitter()
this.blobs = {}
}
applyRoutes() {
this.app.get(
'/api/projects/:project_id/version/:version/zip',
(req, res, next) => {
this.sentChunks++
zipAttachment(
res,
`Mock zip for ${req.params.project_id} at version ${req.params.version}`,
'project.zip'
)
}
)
this.app.get(
'/fake-zip-download/:project_id/version/:version',
(req, res, next) => {
if (!(this.fakeZipCall++ > 0)) {
return res.sendStatus(404)
}
if (req.params.version === '42') {
return zipAttachment(
res,
`Mock zip for ${req.params.project_id} at version ${req.params.version}`,
'project.zip'
)
}
prepareZipAttachment(res, 'project.zip')
const writeChunk = () => {
res.write('chunk' + this.sentChunks++)
}
const writeEvery = interval => {
if (req.destroyed) return
// setInterval delays the first run
writeChunk()
const periodicWrite = setInterval(writeChunk, interval)
req.on('close', () => clearInterval(periodicWrite))
const deadLine = setTimeout(() => {
clearInterval(periodicWrite)
res.end()
}, 10 * 1000)
res.on('end', () => clearTimeout(deadLine))
}
if (req.params.version === '100') {
return writeEvery(100)
}
res.sendStatus(400)
}
)
this.app.post(
'/api/projects/:project_id/version/:version/zip',
(req, res, next) => {
this.requestedZipPacks++
this.events.emit('v1-history-pack-zip')
res.json({
zipUrl: `http://127.0.0.1:23100/fake-zip-download/${req.params.project_id}/version/${req.params.version}`,
})
}
)
this.app.delete('/api/projects/:project_id', (req, res, next) => {
res.sendStatus(204)
})
this.app.put('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const { projectId, hash } = req.params
if (!this.blobs[projectId]) {
this.blobs[projectId] = {}
}
this.blobs[projectId][hash] = Buffer.concat(chunks)
res.sendStatus(200)
})
})
this.app.head('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const { projectId, hash } = req.params
const buf = this.blobs[projectId]?.[hash]
if (!buf) return res.status(404).end()
res.set('Content-Length', buf.byteLength)
res.status(200).end()
})
this.app.get('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const { projectId, hash } = req.params
const buf = this.blobs[projectId]?.[hash]
if (!buf) return res.status(404).end()
res.status(200).end(buf)
})
this.app.post('/api/projects/:project_id/blobs/:hash', (req, res, next) => {
const schema = Joi.object({
copyFrom: Joi.number().required(),
})
const { error } = schema.validate(req.query)
if (error) {
return res.sendStatus(400)
}
res.sendStatus(204)
})
}
}
export default MockV1HistoryApi
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockV1HistoryApi
* @static
* @returns {MockV1HistoryApi}
*/