first commit
This commit is contained in:
11
services/web/test/acceptance/bootstrap.js
vendored
Normal file
11
services/web/test/acceptance/bootstrap.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
const chai = require('chai')
|
||||
chai.should()
|
||||
chai.use(require('chai-as-promised'))
|
||||
chai.use(require('sinon-chai'))
|
||||
chai.use(require('chai-exclude'))
|
||||
|
||||
// Do not truncate assertion errors
|
||||
chai.config.truncateThreshold = 0
|
||||
|
||||
// ensure every ObjectId has the id string as a property for correct comparisons
|
||||
require('mongodb-legacy').ObjectId.cacheHexString = true
|
||||
275
services/web/test/acceptance/config/settings.test.defaults.js
Normal file
275
services/web/test/acceptance/config/settings.test.defaults.js
Normal file
@@ -0,0 +1,275 @@
|
||||
const { merge } = require('@overleaf/settings/merge')
|
||||
|
||||
let features
|
||||
|
||||
const httpAuthUser = 'overleaf'
|
||||
const httpAuthPass = 'password'
|
||||
const httpAuthUsers = {}
|
||||
httpAuthUsers[httpAuthUser] = httpAuthPass
|
||||
|
||||
module.exports = {
|
||||
catchErrors: false,
|
||||
clsiCookie: undefined,
|
||||
|
||||
cacheStaticAssets: true,
|
||||
|
||||
httpAuthUsers,
|
||||
secureCookie: false,
|
||||
security: {
|
||||
sessionSecret: 'static-secret-for-tests',
|
||||
sessionSecretUpcoming: 'static-secret-upcoming-for-tests',
|
||||
sessionSecretFallback: 'static-secret-fallback-for-tests',
|
||||
},
|
||||
adminDomains: process.env.ADMIN_DOMAINS
|
||||
? JSON.parse(process.env.ADMIN_DOMAINS)
|
||||
: ['example.com'],
|
||||
|
||||
statusPageUrl: 'status.example.com',
|
||||
cdn: {
|
||||
web: {
|
||||
host: 'cdn.example.com',
|
||||
},
|
||||
},
|
||||
|
||||
mongo: {
|
||||
options: {
|
||||
family: 4,
|
||||
},
|
||||
},
|
||||
|
||||
apis: {
|
||||
linkedUrlProxy: {
|
||||
url: process.env.LINKED_URL_PROXY,
|
||||
},
|
||||
|
||||
web: {
|
||||
url: 'http://127.0.0.1:23000',
|
||||
user: httpAuthUser,
|
||||
pass: httpAuthPass,
|
||||
},
|
||||
|
||||
haveIBeenPwned: {
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:1337',
|
||||
},
|
||||
documentupdater: {
|
||||
url: 'http://127.0.0.1:23003',
|
||||
},
|
||||
docstore: {
|
||||
url: 'http://127.0.0.1:23016',
|
||||
pubUrl: 'http://127.0.0.1:23016',
|
||||
},
|
||||
chat: {
|
||||
internal_url: 'http://127.0.0.1:23010',
|
||||
},
|
||||
filestore: {
|
||||
url: 'http://127.0.0.1:23009',
|
||||
},
|
||||
clsi: {
|
||||
url: 'http://127.0.0.1:23013',
|
||||
},
|
||||
realTime: {
|
||||
url: 'http://127.0.0.1:23026',
|
||||
},
|
||||
contacts: {
|
||||
url: 'http://127.0.0.1:23036',
|
||||
},
|
||||
notifications: {
|
||||
url: 'http://127.0.0.1:23042',
|
||||
},
|
||||
project_history: {
|
||||
sendProjectStructureOps: true,
|
||||
url: `http://127.0.0.1:23054`,
|
||||
},
|
||||
v1_history: {
|
||||
url: `http://127.0.0.1:23100/api`,
|
||||
user: 'overleaf',
|
||||
pass: 'password',
|
||||
},
|
||||
historyBackupDeletion: {
|
||||
url: `http://127.0.0.1:23101`,
|
||||
user: 'overleaf',
|
||||
pass: 'password',
|
||||
},
|
||||
webpack: {
|
||||
url: 'http://127.0.0.1:23808',
|
||||
},
|
||||
gitBridge: {
|
||||
url: 'http://127.0.0.1:28000',
|
||||
},
|
||||
},
|
||||
|
||||
features: (features = {
|
||||
v1_free: {
|
||||
collaborators: 1,
|
||||
dropbox: false,
|
||||
versioning: false,
|
||||
github: true,
|
||||
gitBridge: true,
|
||||
references: false,
|
||||
referencesSearch: false,
|
||||
mendeley: true,
|
||||
papers: true,
|
||||
zotero: true,
|
||||
compileTimeout: 60,
|
||||
compileGroup: 'standard',
|
||||
trackChanges: false,
|
||||
symbolPalette: false,
|
||||
aiErrorAssistant: false,
|
||||
},
|
||||
personal: {
|
||||
collaborators: 1,
|
||||
dropbox: false,
|
||||
versioning: false,
|
||||
github: false,
|
||||
gitBridge: false,
|
||||
references: false,
|
||||
referencesSearch: false,
|
||||
mendeley: false,
|
||||
papers: false,
|
||||
zotero: false,
|
||||
compileTimeout: 60,
|
||||
compileGroup: 'standard',
|
||||
trackChanges: false,
|
||||
symbolPalette: false,
|
||||
aiErrorAssistant: false,
|
||||
},
|
||||
collaborator: {
|
||||
collaborators: 10,
|
||||
dropbox: true,
|
||||
versioning: true,
|
||||
github: true,
|
||||
gitBridge: true,
|
||||
references: true,
|
||||
referencesSearch: true,
|
||||
mendeley: true,
|
||||
papers: true,
|
||||
zotero: true,
|
||||
compileTimeout: 180,
|
||||
compileGroup: 'priority',
|
||||
trackChanges: true,
|
||||
symbolPalette: true,
|
||||
aiErrorAssistant: false,
|
||||
},
|
||||
professional: {
|
||||
collaborators: -1,
|
||||
dropbox: true,
|
||||
versioning: true,
|
||||
github: true,
|
||||
gitBridge: true,
|
||||
references: true,
|
||||
referencesSearch: true,
|
||||
mendeley: true,
|
||||
papers: true,
|
||||
zotero: true,
|
||||
compileTimeout: 180,
|
||||
compileGroup: 'priority',
|
||||
trackChanges: true,
|
||||
symbolPalette: true,
|
||||
aiErrorAssistant: false,
|
||||
},
|
||||
}),
|
||||
|
||||
defaultFeatures: features.personal,
|
||||
defaultPlanCode: 'personal',
|
||||
institutionPlanCode: 'professional',
|
||||
|
||||
plans: [
|
||||
{
|
||||
planCode: 'v1_free',
|
||||
name: 'V1 Free',
|
||||
price_in_cents: 0,
|
||||
features: features.v1_free,
|
||||
},
|
||||
{
|
||||
planCode: 'personal',
|
||||
name: 'Personal',
|
||||
price_in_cents: 0,
|
||||
features: features.personal,
|
||||
},
|
||||
{
|
||||
planCode: 'collaborator',
|
||||
name: 'Collaborator',
|
||||
price_in_cents: 1500,
|
||||
features: features.collaborator,
|
||||
},
|
||||
{
|
||||
planCode: 'professional',
|
||||
name: 'Professional',
|
||||
price_in_cents: 3000,
|
||||
features: features.professional,
|
||||
},
|
||||
],
|
||||
|
||||
bonus_features: {
|
||||
1: {
|
||||
collaborators: 2,
|
||||
dropbox: false,
|
||||
versioning: false,
|
||||
},
|
||||
3: {
|
||||
collaborators: 4,
|
||||
dropbox: false,
|
||||
versioning: false,
|
||||
},
|
||||
6: {
|
||||
collaborators: 4,
|
||||
dropbox: true,
|
||||
versioning: true,
|
||||
},
|
||||
9: {
|
||||
collaborators: -1,
|
||||
dropbox: true,
|
||||
versioning: true,
|
||||
},
|
||||
},
|
||||
|
||||
redirects: {
|
||||
'/redirect/one': '/destination/one',
|
||||
'/redirect/get_and_post': {
|
||||
methods: ['get', 'post'],
|
||||
url: '/destination/get_and_post',
|
||||
},
|
||||
'/redirect/base_url': {
|
||||
baseUrl: 'https://example.com',
|
||||
url: '/destination/base_url',
|
||||
},
|
||||
'/redirect/params/:id': {
|
||||
url(params) {
|
||||
return `/destination/${params.id}/params`
|
||||
},
|
||||
},
|
||||
'/redirect/qs': '/destination/qs',
|
||||
'/docs_v1': {
|
||||
url: '/docs',
|
||||
},
|
||||
},
|
||||
|
||||
reconfirmNotificationDays: 14,
|
||||
|
||||
recaptcha: {
|
||||
siteKey: 'siteKey',
|
||||
disabled: {
|
||||
invite: true,
|
||||
login: false,
|
||||
passwordReset: true,
|
||||
register: true,
|
||||
addEmail: true,
|
||||
},
|
||||
},
|
||||
|
||||
// No email in tests
|
||||
email: undefined,
|
||||
|
||||
test: {
|
||||
counterInit: 0,
|
||||
},
|
||||
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports.mergeWith = function (overrides) {
|
||||
return merge(overrides, module.exports)
|
||||
}
|
||||
102
services/web/test/acceptance/config/settings.test.saas.js
Normal file
102
services/web/test/acceptance/config/settings.test.saas.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const { merge } = require('@overleaf/settings/merge')
|
||||
const baseApp = require('../../../config/settings.overrides.saas')
|
||||
const baseTest = require('./settings.test.defaults')
|
||||
|
||||
const httpAuthUser = 'overleaf'
|
||||
const httpAuthPass = 'password'
|
||||
const httpAuthUsers = {}
|
||||
httpAuthUsers[httpAuthUser] = httpAuthPass
|
||||
|
||||
const overleafHost =
|
||||
process.env.V2_URL ||
|
||||
`http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
|
||||
|
||||
const overrides = {
|
||||
appName: 'Overleaf',
|
||||
siteUrl: overleafHost,
|
||||
|
||||
enableSubscriptions: true,
|
||||
|
||||
apis: {
|
||||
thirdPartyDataStore: {
|
||||
url: `http://127.0.0.1:23002`,
|
||||
dropboxApp: 'Overleaf',
|
||||
},
|
||||
analytics: {
|
||||
url: `http://127.0.0.1:23050`,
|
||||
},
|
||||
recurly: {
|
||||
url: 'http://127.0.0.1:26034',
|
||||
subdomain: 'test',
|
||||
apiKey: 'private-nonsense',
|
||||
webhookUser: 'recurly',
|
||||
webhookPass: 'webhook',
|
||||
},
|
||||
|
||||
tpdsworker: {
|
||||
// Disable tpdsworker in CI.
|
||||
url: undefined,
|
||||
},
|
||||
|
||||
v1: {
|
||||
url: `http://127.0.0.1:25000`,
|
||||
user: 'overleaf',
|
||||
pass: 'password',
|
||||
},
|
||||
tags: {
|
||||
url: 'http://127.0.0.1:25000',
|
||||
},
|
||||
},
|
||||
|
||||
oauthProviders: {
|
||||
provider: {
|
||||
name: 'provider',
|
||||
},
|
||||
collabratec: {
|
||||
name: 'collabratec',
|
||||
},
|
||||
google: {
|
||||
name: 'google',
|
||||
},
|
||||
},
|
||||
|
||||
saml: undefined,
|
||||
|
||||
contentful: {
|
||||
spaceId: 'a',
|
||||
deliveryToken: 'b',
|
||||
previewToken: 'c',
|
||||
deliveryApiHost: 'cdn.contentful.com',
|
||||
previewApiHost: 'preview.contentful.com',
|
||||
},
|
||||
|
||||
twoFactorAuthentication: {
|
||||
accessTokenEncryptorOptions: {
|
||||
cipherPasswords: {
|
||||
'2023.1-v3': 'this-is-a-weak-secret-for-tests-web-2023.1-v3',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
overleaf: {
|
||||
host: 'http://127.0.0.1:25000',
|
||||
oauth: {
|
||||
clientID: 'mock-oauth-client-id',
|
||||
clientSecret: 'mock-oauth-client-secret',
|
||||
},
|
||||
},
|
||||
|
||||
analytics: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = baseApp.mergeWith(baseTest.mergeWith(overrides))
|
||||
|
||||
for (const redisKey of Object.keys(module.exports.redis)) {
|
||||
module.exports.redis[redisKey].host = process.env.REDIS_HOST || '127.0.0.1'
|
||||
}
|
||||
|
||||
module.exports.mergeWith = function (overrides) {
|
||||
return merge(overrides, module.exports)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const { merge } = require('@overleaf/settings/merge')
|
||||
const ServerCEDefaults = require('../../../config/settings.defaults')
|
||||
const base = require('./settings.test.defaults')
|
||||
|
||||
module.exports = base.mergeWith({
|
||||
defaultFeatures: ServerCEDefaults.defaultFeatures,
|
||||
activeUserMetricInterval: 100,
|
||||
})
|
||||
|
||||
module.exports.mergeWith = function (overrides) {
|
||||
return merge(overrides, module.exports)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const { merge } = require('@overleaf/settings/merge')
|
||||
const baseApp = require('../../../config/settings.overrides.server-pro')
|
||||
const baseTestServerCE = require('./settings.test.server-ce')
|
||||
|
||||
module.exports = baseApp.mergeWith(
|
||||
baseTestServerCE.mergeWith({
|
||||
proxyLearn: true,
|
||||
})
|
||||
)
|
||||
|
||||
module.exports.mergeWith = function (overrides) {
|
||||
return merge(overrides, module.exports)
|
||||
}
|
||||
BIN
services/web/test/acceptance/files/1pixel.png
Normal file
BIN
services/web/test/acceptance/files/1pixel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
services/web/test/acceptance/files/2pixel.png
Normal file
BIN
services/web/test/acceptance/files/2pixel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,8 @@
|
||||
\documentclass[12pt]{article}
|
||||
\usepackage[german,english]{babel}
|
||||
\usepackage[utf8x]{inputenc}
|
||||
\begin{document}
|
||||
\title{Untitled}
|
||||
\selectlanguage{german}
|
||||
Der schnelle braune Fuchs sprang träge über den Hund.
|
||||
\end{document}
|
||||
@@ -0,0 +1,8 @@
|
||||
\documentclass[12pt]{article}
|
||||
\usepackage[german,english]{babel}
|
||||
\usepackage[cp1252]{inputenc}
|
||||
\begin{document}
|
||||
\title{Untitled}
|
||||
\selectlanguage{german}
|
||||
Der schnelle braune Fuchs sprang tr<74>ge <20>ber den Hund.
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
\documentclass[12pt]{article}
|
||||
\usepackage[greek,english]{babel}
|
||||
\usepackage[utf8x]{inputenc}
|
||||
\begin{document}
|
||||
\title{Untitled}
|
||||
\selectlanguage{greek}
|
||||
Η γρήγορη καστανή αλεπού πήδηξε χαλαρά πάνω από το σκυλί.
|
||||
\end{document}
|
||||
22
services/web/test/acceptance/files/crash_test_urls/basic.txt
Normal file
22
services/web/test/acceptance/files/crash_test_urls/basic.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
/
|
||||
//
|
||||
/user/contacts
|
||||
/user/password/reset
|
||||
/user/password/set
|
||||
/home
|
||||
/user/subscription
|
||||
/subscription/invites/
|
||||
/login
|
||||
/restricted
|
||||
/register
|
||||
/user/bonus
|
||||
/system/messages
|
||||
/user/settings
|
||||
/user/projects
|
||||
/project
|
||||
/api/project
|
||||
/project/download/zip
|
||||
/tag
|
||||
/notifications
|
||||
/beta/participate
|
||||
/unsupported-browser
|
||||
20
services/web/test/acceptance/files/saml-cert.crt
Normal file
20
services/web/test/acceptance/files/saml-cert.crt
Normal file
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDWzCCAkOgAwIBAgIUMYF933mpvZjZwxuweemukpsawsEwDQYJKoZIhvcNAQEF
|
||||
BQAwPTELMAkGA1UEBhMCVUsxCzAJBgNVBAgMAlVLMSEwHwYDVQQKDBhJbnRlcm5l
|
||||
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjAwMzA1MTU0NjQ0WhcNMzAwMzAzMTU0NjQ0
|
||||
WjA9MQswCQYDVQQGEwJVSzELMAkGA1UECAwCVUsxITAfBgNVBAoMGEludGVybmV0
|
||||
IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
ANgiv2BzCV/xAN3U0miBRnUEg/vwTxqL4Ibzuf4H1X9Kael8jsM2GVEo0D4ot+RK
|
||||
nwDjx22hJaNF7uA8WG+tuVUA7m6JGYw1jB/3Pa6MxUDYl2dgQQetnIKHWX+Gq2GE
|
||||
aY734P+kopc0FEUVRp/ZWfEEI74r0rpT2mdW/pLFAJKc+zK+vIBgU40WEdeT0/Vo
|
||||
0x2J0sqAT56td4XHYlg29Y7eARTq2+Z00eM8lJC4KzD9LM6Ut3Ea4mg1juaIAXKy
|
||||
kcmJ+PbO0tzZPf7V+ZY66lrU4vye6oig23D5A0uC9LkwtDPEW5vCmvFnEwBHo/cZ
|
||||
TXldG5Pw9+Ja8o4W+vs7O9sCAwEAAaNTMFEwHQYDVR0OBBYEFPS6b8k/u0hA4woS
|
||||
kHF8Wc6AW1qkMB8GA1UdIwQYMBaAFPS6b8k/u0hA4woSkHF8Wc6AW1qkMA8GA1Ud
|
||||
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBADWigiC7md2smgPH2Wo4W0Aw
|
||||
ON+gMfF3hsn1K6M3ce4ou61gcwGOKWsvxdCAvArhGVY0ijw0Tq47K/zqrmWoWute
|
||||
LtKPweVgAgvcOCro3NPVdXb46k/u8305es7LWNksJ3PaMw35GU6bhBrsUfGPeM6n
|
||||
J9QDUJiFCxwQTATfOwlNZucfTmdBLspajhNsjuKb3TqqKzy5a5nHkEWjEJrpcSg+
|
||||
P5HoTVQIzedaY2J2D8peE0V9zFDPhq3SsVxAXdyoGSNAXa9unGZyGbfH1/GeHwOn
|
||||
UTMZerv5c5Nv1MtOgwEWi7NkqWhAIf6rZDpXWLwZ1V258yhpwQ371MqklzJbyaY=
|
||||
-----END CERTIFICATE-----
|
||||
28
services/web/test/acceptance/files/saml-key.pem
Normal file
28
services/web/test/acceptance/files/saml-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDYIr9gcwlf8QDd
|
||||
1NJogUZ1BIP78E8ai+CG87n+B9V/SmnpfI7DNhlRKNA+KLfkSp8A48dtoSWjRe7g
|
||||
PFhvrblVAO5uiRmMNYwf9z2ujMVA2JdnYEEHrZyCh1l/hqthhGmO9+D/pKKXNBRF
|
||||
FUaf2VnxBCO+K9K6U9pnVv6SxQCSnPsyvryAYFONFhHXk9P1aNMdidLKgE+erXeF
|
||||
x2JYNvWO3gEU6tvmdNHjPJSQuCsw/SzOlLdxGuJoNY7miAFyspHJifj2ztLc2T3+
|
||||
1fmWOupa1OL8nuqIoNtw+QNLgvS5MLQzxFubwprxZxMAR6P3GU15XRuT8PfiWvKO
|
||||
Fvr7OzvbAgMBAAECggEBAMaFhArvHtlE4GrhJDJhK3ooH6K1Y7Mab60FCP1P7MXy
|
||||
b73KbsbXVgG53yx48g96ivmiPndv4MZLYdING53Yj7aIGHjm7NRgCskBq2I8YqHh
|
||||
T4/gVVrcGDm8YHRGGfyERwDOpZeqfL0tVMDvfeMtHPPHvZzbW79RbfYlbccZtCD0
|
||||
54PPnXqcKBx/gsWi6RrRFWYChBpmMjPrxz/SOSIYVvxBk3rY7aikckP/XT6dR71K
|
||||
1Ifqqa6ihi8bx9NLEJPCWmMCNMmwDG5iJXr9gtMJAUnr+K8yD4vkDDwgDilU8mTC
|
||||
UtfcbYyl1lOnPGUjLx9xKuk9bsk4k/uDsBfUWK/lEgECgYEA78dlfsfQArOuAO8M
|
||||
GdU1R+OtLAofl1wyhcpDDcQZq9MMFvBCBSKkbw+Rg1IY/4WApKvPY9RbiVSFU8Cp
|
||||
d31JgW9wFADOhXtVlpRDZPJXXdS2zJaJatD3PrqIgKnNxKyqzz9gjuGMCiwBMujm
|
||||
FqrmkOREAN+1jqcSgsVuDQld2hsCgYEA5sHjQ37JftyJxKSHmKhZTr0I7BoRStox
|
||||
nOq3aSBqaWfihMFY1WW3eCyCoG+EltiJtScLRPor6MebPm9cqOPKnQcnC3l59OVW
|
||||
3UTC3g7JjflyiViminXMHDFtsZfpxSkzYA3+oPQJVl2bSj0ud21eXC/y2KN+8KO3
|
||||
Kd7KGVJyQUECgYEAsNiPswIMGPIM1AN7GVJ3CZ6Sinis9CW73ZFgAzcu99ugfwqU
|
||||
ptT2EjOZTxGt/keoqctOGoL1QERmUW83jjmJjT1znE08BJcCeRzA2CMk7L+GUz5z
|
||||
+6RDtrA9HSgf636uPEyyGq+faaErATFlAjLp+tNglIRqk9wFew3CLTtLTSECgYEA
|
||||
mzpWXPMPLK3CZ2ueY4zr9tGnDNxEQawhr8Mc+jT6IEnn0RIXZgX0s3yNqssZ0Dd9
|
||||
+0R2ikIYA5Ey138mP95sT9Gd7FQdPCaClnpI9APShhUFfWsLLR0s3tJJTiw4745V
|
||||
pwoC/dbr6RMzAW/CsEf8L9t5a04geFRJRHtATGRvw4ECgYEAwZ2PUuNZsmtGj0/o
|
||||
VneVCKNrUBMNDR5fOcMHKsNmowgDUxW0hEwa2JI1Zj5lLnqPbsgAQqP+j8AyfPAB
|
||||
5wCQb+fV5NmZW15GB/7dMkISaLvwBoA9qKK2MO2szWDRpMG6rkF6dzWhLOKgqaL0
|
||||
vsQx+F6ymMvXw0pnQf9/Qqxkp7k=
|
||||
-----END PRIVATE KEY-----
|
||||
1
services/web/test/acceptance/files/test.tex
Normal file
1
services/web/test/acceptance/files/test.tex
Normal file
@@ -0,0 +1 @@
|
||||
Test
|
||||
13
services/web/test/acceptance/getModuleTargets.js
Normal file
13
services/web/test/acceptance/getModuleTargets.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable no-console */
|
||||
// silence settings module
|
||||
console.log = function () {}
|
||||
const Settings = require('@overleaf/settings')
|
||||
|
||||
const MODULES = Settings.moduleImportSequence
|
||||
const TARGET = process.argv.slice(2).pop() || 'test_acceptance'
|
||||
|
||||
if (TARGET === '--name-only') {
|
||||
console.debug(MODULES.join('\n'))
|
||||
} else {
|
||||
console.debug(MODULES.map(name => `modules/${name}/${TARGET}`).join('\n'))
|
||||
}
|
||||
36
services/web/test/acceptance/src/ActiveUsersMetricTests.mjs
Normal file
36
services/web/test/acceptance/src/ActiveUsersMetricTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
193
services/web/test/acceptance/src/AddSecondaryEmailTests.mjs
Normal file
193
services/web/test/acceptance/src/AddSecondaryEmailTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
64
services/web/test/acceptance/src/AdminEmailTests.mjs
Normal file
64
services/web/test/acceptance/src/AdminEmailTests.mjs
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
70
services/web/test/acceptance/src/AdminOnlyLoginTests.mjs
Normal file
70
services/web/test/acceptance/src/AdminOnlyLoginTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
155
services/web/test/acceptance/src/AuthenticationTests.mjs
Normal file
155
services/web/test/acceptance/src/AuthenticationTests.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
766
services/web/test/acceptance/src/AuthorizationTests.mjs
Normal file
766
services/web/test/acceptance/src/AuthorizationTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
176
services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs
Normal file
176
services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
79
services/web/test/acceptance/src/BackFillDocRevTests.mjs
Normal file
79
services/web/test/acceptance/src/BackFillDocRevTests.mjs
Normal 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
273
services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs
Normal file
273
services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs
Normal 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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
164
services/web/test/acceptance/src/BatchedUpdateTests.mjs
Normal file
164
services/web/test/acceptance/src/BatchedUpdateTests.mjs
Normal 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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
33
services/web/test/acceptance/src/BetaProgramTests.mjs
Normal file
33
services/web/test/acceptance/src/BetaProgramTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
87
services/web/test/acceptance/src/BodyParserErrorsTest.mjs
Normal file
87
services/web/test/acceptance/src/BodyParserErrorsTest.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
97
services/web/test/acceptance/src/CDNMigrationTests.mjs
Normal file
97
services/web/test/acceptance/src/CDNMigrationTests.mjs
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
261
services/web/test/acceptance/src/CaptchaTests.mjs
Normal file
261
services/web/test/acceptance/src/CaptchaTests.mjs
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
67
services/web/test/acceptance/src/CloseSiteTests.mjs
Normal file
67
services/web/test/acceptance/src/CloseSiteTests.mjs
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
208
services/web/test/acceptance/src/ConvertArchivedState.mjs
Normal file
208
services/web/test/acceptance/src/ConvertArchivedState.mjs
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
258
services/web/test/acceptance/src/CookieMetricsTests.mjs
Normal file
258
services/web/test/acceptance/src/CookieMetricsTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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] },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
591
services/web/test/acceptance/src/DeletionTests.mjs
Normal file
591
services/web/test/acceptance/src/DeletionTests.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
157
services/web/test/acceptance/src/DocUpdateTests.mjs
Normal file
157
services/web/test/acceptance/src/DocUpdateTests.mjs
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
238
services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs
Normal file
238
services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs
Normal 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 you’ve entered is on a public list of compromised passwords (https://haveibeenpwned.com/passwords). Please try logging in from a device you’ve 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
279
services/web/test/acceptance/src/HistoryTests.mjs
Normal file
279
services/web/test/acceptance/src/HistoryTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
44
services/web/test/acceptance/src/Init.mjs
Normal file
44
services/web/test/acceptance/src/Init.mjs
Normal 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)
|
||||
}
|
||||
54
services/web/test/acceptance/src/LearnTest.mjs
Normal file
54
services/web/test/acceptance/src/LearnTest.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
527
services/web/test/acceptance/src/LinkedFilesTests.mjs
Normal file
527
services/web/test/acceptance/src/LinkedFilesTests.mjs
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
733
services/web/test/acceptance/src/MalformedFiletreesTests.mjs
Normal file
733
services/web/test/acceptance/src/MalformedFiletreesTests.mjs
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
82
services/web/test/acceptance/src/ModelTests.mjs
Normal file
82
services/web/test/acceptance/src/ModelTests.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
197
services/web/test/acceptance/src/MongoHelper.mjs
Normal file
197
services/web/test/acceptance/src/MongoHelper.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
502
services/web/test/acceptance/src/PasswordResetTests.mjs
Normal file
502
services/web/test/acceptance/src/PasswordResetTests.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
212
services/web/test/acceptance/src/PasswordUpdateTests.mjs
Normal file
212
services/web/test/acceptance/src/PasswordUpdateTests.mjs
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
332
services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs
Normal file
332
services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs
Normal 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()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
192
services/web/test/acceptance/src/ProjectCRUDTests.mjs
Normal file
192
services/web/test/acceptance/src/ProjectCRUDTests.mjs
Normal 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)
|
||||
}
|
||||
696
services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs
Normal file
696
services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
97
services/web/test/acceptance/src/ProjectFeaturesTests.mjs
Normal file
97
services/web/test/acceptance/src/ProjectFeaturesTests.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
865
services/web/test/acceptance/src/ProjectInviteTests.mjs
Normal file
865
services/web/test/acceptance/src/ProjectInviteTests.mjs
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
317
services/web/test/acceptance/src/ProjectStructureTests.mjs
Normal file
317
services/web/test/acceptance/src/ProjectStructureTests.mjs
Normal 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()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
92
services/web/test/acceptance/src/RedirectUrlsTests.mjs
Normal file
92
services/web/test/acceptance/src/RedirectUrlsTests.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
414
services/web/test/acceptance/src/RegistrationTests.mjs
Normal file
414
services/web/test/acceptance/src/RegistrationTests.mjs
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
203
services/web/test/acceptance/src/SecurityHeadersTests.mjs
Normal file
203
services/web/test/acceptance/src/SecurityHeadersTests.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
35
services/web/test/acceptance/src/ServerCrashTests.mjs
Normal file
35
services/web/test/acceptance/src/ServerCrashTests.mjs
Normal 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}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
559
services/web/test/acceptance/src/SessionTests.mjs
Normal file
559
services/web/test/acceptance/src/SessionTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
services/web/test/acceptance/src/SettingsTests.mjs
Normal file
61
services/web/test/acceptance/src/SettingsTests.mjs
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
152
services/web/test/acceptance/src/SharingTests.mjs
Normal file
152
services/web/test/acceptance/src/SharingTests.mjs
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
388
services/web/test/acceptance/src/TagsTests.mjs
Normal file
388
services/web/test/acceptance/src/TagsTests.mjs
Normal 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()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1455
services/web/test/acceptance/src/TokenAccessTests.mjs
Normal file
1455
services/web/test/acceptance/src/TokenAccessTests.mjs
Normal file
File diff suppressed because it is too large
Load Diff
200
services/web/test/acceptance/src/TpdsUpdateTests.mjs
Normal file
200
services/web/test/acceptance/src/TpdsUpdateTests.mjs
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
201
services/web/test/acceptance/src/UnsupportedBrowserTests.mjs
Normal file
201
services/web/test/acceptance/src/UnsupportedBrowserTests.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
204
services/web/test/acceptance/src/UserHelperTests.mjs
Normal file
204
services/web/test/acceptance/src/UserHelperTests.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
64
services/web/test/acceptance/src/UserReconfirmTests.mjs
Normal file
64
services/web/test/acceptance/src/UserReconfirmTests.mjs
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
116
services/web/test/acceptance/src/helpers/InitApp.mjs
Normal file
116
services/web/test/acceptance/src/helpers/InitApp.mjs
Normal 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')
|
||||
})
|
||||
37
services/web/test/acceptance/src/helpers/Institution.mjs
Normal file
37
services/web/test/acceptance/src/helpers/Institution.mjs
Normal 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
|
||||
39
services/web/test/acceptance/src/helpers/MongoHelper.mjs
Normal file
39
services/web/test/acceptance/src/helpers/MongoHelper.mjs
Normal 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)
|
||||
},
|
||||
}
|
||||
40
services/web/test/acceptance/src/helpers/Publisher.mjs
Normal file
40
services/web/test/acceptance/src/helpers/Publisher.mjs
Normal 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
|
||||
@@ -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'],
|
||||
})
|
||||
10
services/web/test/acceptance/src/helpers/RedisHelper.mjs
Normal file
10
services/web/test/acceptance/src/helpers/RedisHelper.mjs
Normal 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)
|
||||
})
|
||||
},
|
||||
}
|
||||
254
services/web/test/acceptance/src/helpers/SAMLHelper.mjs
Normal file
254
services/web/test/acceptance/src/helpers/SAMLHelper.mjs
Normal 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
|
||||
60
services/web/test/acceptance/src/helpers/SplitTestHelper.mjs
Normal file
60
services/web/test/acceptance/src/helpers/SplitTestHelper.mjs
Normal 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,
|
||||
}
|
||||
181
services/web/test/acceptance/src/helpers/Subscription.mjs
Normal file
181
services/web/test/acceptance/src/helpers/Subscription.mjs
Normal 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
|
||||
1327
services/web/test/acceptance/src/helpers/User.mjs
Normal file
1327
services/web/test/acceptance/src/helpers/User.mjs
Normal file
File diff suppressed because it is too large
Load Diff
559
services/web/test/acceptance/src/helpers/UserHelper.mjs
Normal file
559
services/web/test/acceptance/src/helpers/UserHelper.mjs
Normal 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
|
||||
@@ -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' })
|
||||
},
|
||||
},
|
||||
}
|
||||
220
services/web/test/acceptance/src/helpers/groupSSO.mjs
Normal file
220
services/web/test/acceptance/src/helpers/groupSSO.mjs
Normal 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,
|
||||
}
|
||||
39
services/web/test/acceptance/src/helpers/injectRoute.mjs
Normal file
39
services/web/test/acceptance/src/helpers/injectRoute.mjs
Normal 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 }
|
||||
25
services/web/test/acceptance/src/helpers/metrics.mjs
Normal file
25
services/web/test/acceptance/src/helpers/metrics.mjs
Normal 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,
|
||||
},
|
||||
}
|
||||
57
services/web/test/acceptance/src/helpers/redis.mjs
Normal file
57
services/web/test/acceptance/src/helpers/redis.mjs
Normal 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)
|
||||
})
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
24
services/web/test/acceptance/src/helpers/request.js
Normal file
24
services/web/test/acceptance/src/helpers/request.js
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
11
services/web/test/acceptance/src/helpers/settings.mjs
Normal file
11
services/web/test/acceptance/src/helpers/settings.mjs
Normal 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 }
|
||||
193
services/web/test/acceptance/src/mocks/AbstractMockApi.mjs
Normal file
193
services/web/test/acceptance/src/mocks/AbstractMockApi.mjs
Normal 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
|
||||
41
services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs
Normal file
41
services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs
Normal 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}
|
||||
*/
|
||||
52
services/web/test/acceptance/src/mocks/MockChatApi.mjs
Normal file
52
services/web/test/acceptance/src/mocks/MockChatApi.mjs
Normal 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}
|
||||
*/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user