mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Merge pull request #36219 from overleaf/jpa-zod-web-platform
[web] platform: adopt strict zod schema and extend test coverage GitOrigin-RevId: e3a0b7b4c52ac116f28be0d284284e3bd3994cda
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
const modulePath = new URL(
|
||||
'../../../../app/src/Features/Analytics/AnalyticsController.mjs',
|
||||
@@ -50,11 +51,17 @@ describe('AnalyticsController', function () {
|
||||
ctx.res = new MockResponse(vi)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('updateEditingSession', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// projectId is validated as a Mongo ObjectId
|
||||
ctx.projectId = '507f191e810c19729de860ea'
|
||||
ctx.req = {
|
||||
params: {
|
||||
projectId: 'a project id',
|
||||
projectId: ctx.projectId,
|
||||
},
|
||||
session: {},
|
||||
body: {
|
||||
@@ -75,7 +82,7 @@ describe('AnalyticsController', function () {
|
||||
sinon.assert.calledWith(
|
||||
ctx.AnalyticsManager.updateEditingSession,
|
||||
'1234',
|
||||
'a project id',
|
||||
ctx.projectId,
|
||||
'XY',
|
||||
{ editorType: 'abc' }
|
||||
)
|
||||
@@ -90,7 +97,6 @@ describe('AnalyticsController', function () {
|
||||
beforeEach(function (ctx) {
|
||||
const body = {
|
||||
foo: 'stuff',
|
||||
_csrf: 'atoken123',
|
||||
}
|
||||
ctx.req = {
|
||||
params: {
|
||||
@@ -100,9 +106,6 @@ describe('AnalyticsController', function () {
|
||||
sessionID: 'sessionIDHere',
|
||||
session: {},
|
||||
}
|
||||
|
||||
ctx.expectedData = Object.assign({}, body)
|
||||
delete ctx.expectedData._csrf
|
||||
})
|
||||
|
||||
it('should use the session', async function (ctx) {
|
||||
@@ -112,23 +115,24 @@ describe('AnalyticsController', function () {
|
||||
ctx.AnalyticsManager.recordEventForSession,
|
||||
ctx.req.session,
|
||||
ctx.req.params.event,
|
||||
ctx.expectedData
|
||||
ctx.req.body
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove the CSRF token before sending to the manager', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.controller.recordEvent(ctx.req, ctx.res)
|
||||
sinon.assert.calledWith(
|
||||
ctx.AnalyticsManager.recordEventForSession,
|
||||
ctx.req.session,
|
||||
ctx.req.params.event,
|
||||
ctx.expectedData
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
it('should reject an event name containing characters outside [a-z0-9-_]', function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.req.params.event = 'I.did:something,here/now;too'
|
||||
expect(() => ctx.controller.recordEvent(ctx.req, ctx.res)).to.throw()
|
||||
sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForSession)
|
||||
})
|
||||
|
||||
it('should reject an event name containing a disallowed character', function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.req.params.event = 'bad event!'
|
||||
expect(() => ctx.controller.recordEvent(ctx.req, ctx.res)).to.throw()
|
||||
sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForSession)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
|
||||
const MODULE_PATH = new URL(
|
||||
'../../../../app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware',
|
||||
import.meta.url
|
||||
).pathname
|
||||
|
||||
describe('AnalyticsRegistrationSourceMiddleware', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.req = new MockRequest(vi)
|
||||
ctx.req.header = name => ctx.req.headers[name]
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.next = sinon.stub()
|
||||
|
||||
ctx.AnalyticsRegistrationSourceHelper = {
|
||||
clearSource: sinon.stub(),
|
||||
setInbound: sinon.stub(),
|
||||
}
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Analytics/AnalyticsRegistrationSourceHelper.mjs',
|
||||
() => ({ default: ctx.AnalyticsRegistrationSourceHelper })
|
||||
)
|
||||
|
||||
ctx.SessionManager = {
|
||||
isUserLoggedIn: sinon.stub().returns(false),
|
||||
}
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Authentication/SessionManager.mjs',
|
||||
() => ({ default: ctx.SessionManager })
|
||||
)
|
||||
|
||||
ctx.AnalyticsRegistrationSourceMiddleware = (
|
||||
await import(MODULE_PATH)
|
||||
).default
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('setInbound', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.url = '/project'
|
||||
ctx.req.session = {}
|
||||
ctx.middleware = ctx.AnalyticsRegistrationSourceMiddleware.setInbound()
|
||||
})
|
||||
|
||||
describe('when the session already has an inbound referrer', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.session.inbound = { referrer: { medium: 'direct' } }
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('does not overwrite it', function (ctx) {
|
||||
sinon.assert.notCalled(ctx.AnalyticsRegistrationSourceHelper.setInbound)
|
||||
})
|
||||
|
||||
it('calls next', function (ctx) {
|
||||
sinon.assert.calledOnce(ctx.next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the user is already logged in', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.SessionManager.isUserLoggedIn.returns(true)
|
||||
ctx.req.query = { utm_campaign: 'spring-sale' }
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('does not record a referrer', function (ctx) {
|
||||
sinon.assert.notCalled(ctx.AnalyticsRegistrationSourceHelper.setInbound)
|
||||
})
|
||||
|
||||
it('calls next', function (ctx) {
|
||||
sinon.assert.calledOnce(ctx.next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with well-formed utm query params', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.query = {
|
||||
utm_campaign: 'spring-sale',
|
||||
utm_source: 'newsletter',
|
||||
}
|
||||
ctx.req.headers.referrer = 'https://example.com'
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('forwards the parsed utm query to the helper', function (ctx) {
|
||||
sinon.assert.calledWith(
|
||||
ctx.AnalyticsRegistrationSourceHelper.setInbound,
|
||||
ctx.req.session,
|
||||
ctx.req.url,
|
||||
{ utm_campaign: 'spring-sale', utm_source: 'newsletter' },
|
||||
'https://example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('calls next', function (ctx) {
|
||||
sinon.assert.calledOnce(ctx.next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a malformed utm query value', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.query = { utm_campaign: ['spring-sale', 'other'] }
|
||||
})
|
||||
|
||||
describe('in the default log-only validation mode', function () {
|
||||
beforeEach(function (ctx) {
|
||||
setReqValidationModeForTests('log')
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('falls back to the raw, unvalidated query', function (ctx) {
|
||||
sinon.assert.calledWith(
|
||||
ctx.AnalyticsRegistrationSourceHelper.setInbound,
|
||||
ctx.req.session,
|
||||
ctx.req.url,
|
||||
ctx.req.query,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('calls next', function (ctx) {
|
||||
sinon.assert.calledOnce(ctx.next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with request validation enforced', function () {
|
||||
beforeEach(function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('fails silently without recording a referrer', function (ctx) {
|
||||
sinon.assert.notCalled(
|
||||
ctx.AnalyticsRegistrationSourceHelper.setInbound
|
||||
)
|
||||
})
|
||||
|
||||
it('still calls next', function (ctx) {
|
||||
sinon.assert.calledOnce(ctx.next)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assert, beforeEach, describe, it, vi } from 'vitest'
|
||||
import { afterEach, assert, beforeEach, describe, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
|
||||
@@ -44,6 +45,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
|
||||
ctx.middleware = ctx.AnalyticsUTMTrackingMiddleware.recordUTMTags()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('without UTM tags in query', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.url = '/project'
|
||||
@@ -216,4 +221,50 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a non-string UTM parameter', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.url = '/project?utm_source=Organic&utm_source=Direct'
|
||||
ctx.req.query = {
|
||||
utm_source: ['Organic', 'Direct'],
|
||||
}
|
||||
ctx.req.headers = {
|
||||
host: 'test-domain.overleaf.com',
|
||||
}
|
||||
})
|
||||
|
||||
it('still processes the request without throwing', function (ctx) {
|
||||
setReqValidationModeForTests('log')
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
|
||||
assert.isTrue(ctx.res.redirected)
|
||||
assert.equal('/project', ctx.res.redirectedTo)
|
||||
sinon.assert.calledWith(
|
||||
ctx.AnalyticsManager.recordEventForSession,
|
||||
ctx.req.session,
|
||||
'page-view',
|
||||
{
|
||||
path: '/project',
|
||||
utm_source: ['Organic', 'Direct'],
|
||||
domain: 'test-domain',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('request validation', function () {
|
||||
beforeEach(function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
})
|
||||
|
||||
it('does not throw', function (ctx) {
|
||||
assert.doesNotThrow(() => {
|
||||
ctx.middleware(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
assert.isTrue(ctx.res.redirected)
|
||||
assert.equal('/project', ctx.res.redirectedTo)
|
||||
sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForSession)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
import {
|
||||
NotFoundError,
|
||||
ResourceGoneError,
|
||||
} from '../../../../app/src/Features/Errors/Errors.js'
|
||||
|
||||
const MODULE_PATH =
|
||||
'../../../../app/src/Features/Compile/ClsiCacheController.mjs'
|
||||
|
||||
// pin Errors.js to a single module instance so `instanceof` checks in the
|
||||
// controller (loaded dynamically below, after other modules are mocked)
|
||||
// see the same error classes this file imports statically
|
||||
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
|
||||
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
|
||||
)
|
||||
|
||||
describe('ClsiCacheController', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
// Project_id/editorBuildId are validated by the schema
|
||||
ctx.projectId = '507f191e810c19729de860ea'
|
||||
ctx.userId = '507f191e810c19729de860eb'
|
||||
ctx.editorBuildId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-1111111-2222222'
|
||||
|
||||
ctx.ClsiCacheHandler = {
|
||||
isAllowedFilename: sinon.stub().returns(true),
|
||||
getOutputFile: sinon.stub(),
|
||||
getEgressLabel: sinon.stub().returns('output.pdf'),
|
||||
}
|
||||
ctx.ClsiCacheManager = { getLatestCompileResult: sinon.stub() }
|
||||
ctx.CompileController = {
|
||||
_getUserIdForCompile: sinon.stub().returns(ctx.userId),
|
||||
_getSafeProjectName: sinon.stub().returns('project-name'),
|
||||
_getSplitTestOptions: sinon.stub().resolves({
|
||||
pdfCachingMinChunkSize: 100,
|
||||
pdfDownloadDomain: 'https://example.com',
|
||||
}),
|
||||
}
|
||||
ctx.ProjectGetter = {
|
||||
promises: { getProject: sinon.stub().resolves({ name: 'project' }) },
|
||||
}
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Compile/ClsiCacheHandler', () => ({
|
||||
default: ctx.ClsiCacheHandler,
|
||||
}))
|
||||
vi.doMock('../../../../app/src/Features/Compile/ClsiCacheManager', () => ({
|
||||
default: ctx.ClsiCacheManager,
|
||||
}))
|
||||
vi.doMock('../../../../app/src/Features/Compile/CompileController', () => ({
|
||||
default: ctx.CompileController,
|
||||
}))
|
||||
vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
|
||||
default: ctx.ProjectGetter,
|
||||
}))
|
||||
|
||||
ctx.controller = (await import(MODULE_PATH)).default
|
||||
ctx.req = new MockRequest(vi)
|
||||
ctx.req.params = { Project_id: ctx.projectId }
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.res.json = sinon.stub()
|
||||
ctx.res.sendStatus = sinon.stub()
|
||||
})
|
||||
|
||||
describe('getLatestBuildFromCache', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.result = {
|
||||
zone: 'a',
|
||||
outputFiles: [],
|
||||
compileGroup: 'standard',
|
||||
clsiServerId: 'server-1',
|
||||
clsiCacheShard: 'shard-1',
|
||||
options: {},
|
||||
stats: {},
|
||||
timings: {},
|
||||
}
|
||||
})
|
||||
|
||||
it('should return the cached compile result as json', async function (ctx) {
|
||||
ctx.ClsiCacheManager.getLatestCompileResult.resolves(ctx.result)
|
||||
await ctx.controller.getLatestBuildFromCache(ctx.req, ctx.res)
|
||||
ctx.ClsiCacheManager.getLatestCompileResult
|
||||
.calledWith(ctx.projectId, ctx.userId)
|
||||
.should.equal(true)
|
||||
ctx.res.json
|
||||
.calledWithMatch({
|
||||
fromCache: true,
|
||||
status: 'success',
|
||||
clsiServerId: 'server-1',
|
||||
pdfDownloadDomain: 'https://example.com/zone/a',
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return 404 when the compile result is not found', async function (ctx) {
|
||||
ctx.ClsiCacheManager.getLatestCompileResult.rejects(new NotFoundError())
|
||||
await ctx.controller.getLatestBuildFromCache(ctx.req, ctx.res)
|
||||
ctx.res.sendStatus.calledWith(404).should.equal(true)
|
||||
})
|
||||
|
||||
it('should return 410 when the compile result is gone', async function (ctx) {
|
||||
ctx.ClsiCacheManager.getLatestCompileResult.rejects(
|
||||
new ResourceGoneError()
|
||||
)
|
||||
await ctx.controller.getLatestBuildFromCache(ctx.req, ctx.res)
|
||||
ctx.res.sendStatus.calledWith(410).should.equal(true)
|
||||
})
|
||||
|
||||
it('should reject a malformed project id', async function (ctx) {
|
||||
ctx.req.params = { Project_id: 'not-an-object-id' }
|
||||
await expect(ctx.controller.getLatestBuildFromCache(ctx.req, ctx.res)).to
|
||||
.be.rejected
|
||||
ctx.ClsiCacheManager.getLatestCompileResult.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFromCache', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = {
|
||||
Project_id: ctx.projectId,
|
||||
editorBuildId: ctx.editorBuildId,
|
||||
filename: 'output.pdf',
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a malformed project id', async function (ctx) {
|
||||
ctx.req.params.Project_id = 'not-an-object-id'
|
||||
await expect(ctx.controller.downloadFromCache(ctx.req, ctx.res)).to.be
|
||||
.rejected
|
||||
ctx.ClsiCacheHandler.getOutputFile.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should reject a malformed editorBuildId', async function (ctx) {
|
||||
ctx.req.params.editorBuildId = 'not-a-valid-build-id'
|
||||
await expect(ctx.controller.downloadFromCache(ctx.req, ctx.res)).to.be
|
||||
.rejected
|
||||
ctx.ClsiCacheHandler.getOutputFile.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should reject a path traversal filename', async function (ctx) {
|
||||
ctx.req.params.filename = '../../../etc/passwd'
|
||||
await expect(ctx.controller.downloadFromCache(ctx.req, ctx.res)).to.be
|
||||
.rejected
|
||||
ctx.ClsiCacheHandler.getOutputFile.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should reject a filename rejected by ClsiCacheHandler', async function (ctx) {
|
||||
ctx.ClsiCacheHandler.isAllowedFilename.returns(false)
|
||||
await expect(ctx.controller.downloadFromCache(ctx.req, ctx.res)).to.be
|
||||
.rejected
|
||||
ctx.ClsiCacheHandler.getOutputFile.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
import { Headers } from 'node-fetch'
|
||||
import { ReadableString } from '@overleaf/stream-utils'
|
||||
import { RequestFailedError } from '@overleaf/fetch-utils'
|
||||
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
|
||||
import { asZodError } from '@overleaf/validation-tools/testUtils.js'
|
||||
|
||||
const modulePath = '../../../../app/src/Features/Compile/CompileController.mjs'
|
||||
@@ -390,7 +391,7 @@ describe('CompileController', function () {
|
||||
|
||||
describe('with an editor id', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.req.body = { editorId: 'the-editor-id' }
|
||||
ctx.req.body = { editorId: '550e8400-e29b-41d4-a716-446655440000' }
|
||||
await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
@@ -406,7 +407,7 @@ describe('CompileController', function () {
|
||||
enablePdfCaching: false,
|
||||
fileLineErrors: false,
|
||||
stopOnFirstError: false,
|
||||
editorId: 'the-editor-id',
|
||||
editorId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
rootResourcePath: undefined,
|
||||
}
|
||||
)
|
||||
@@ -657,6 +658,26 @@ describe('CompileController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopCompile', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.CompileManager.promises.stopCompile = sinon.stub().resolves()
|
||||
ctx.req.params = { Project_id: ctx.projectId }
|
||||
ctx.req.session = {}
|
||||
ctx.res.sendStatus = sinon.stub()
|
||||
await ctx.CompileController.stopCompile(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('should stop the compile for the project', function (ctx) {
|
||||
ctx.CompileManager.promises.stopCompile
|
||||
.calledWith(ctx.projectId, ctx.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return a 200', function (ctx) {
|
||||
ctx.res.sendStatus.calledWith(200).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileSubmission', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.submission_id = 'sub-1234'
|
||||
@@ -785,6 +806,22 @@ describe('CompileController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with popupDownload', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
popupDownload: 'true',
|
||||
}
|
||||
await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('should set the content-disposition to attachment', function (ctx) {
|
||||
expect(ctx.res.setContentDisposition).toBeCalledWith('attachment', {
|
||||
filename: 'test_namè__1.pdf',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('anon', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.SessionManager.getLoggedInUserId.returns(null)
|
||||
@@ -798,6 +835,26 @@ describe('CompileController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an enable_pdf_caching query param', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
// output-files.ts appends this to the PDF download link whenever
|
||||
// pdf caching is enabled.
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
enable_pdf_caching: 'true',
|
||||
}
|
||||
await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('should not reject the request', function (ctx) {
|
||||
ctx.next.should.not.have.been.called
|
||||
})
|
||||
|
||||
it('should proxy the PDF from the CLSI', function (ctx) {
|
||||
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('when rate-limited', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.rateLimiter.consume.rejects({
|
||||
@@ -892,6 +949,55 @@ describe('CompileController', function () {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the full query string buildFileList sends for the archive link', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
// services/web/frontend/js/features/pdf-preview/util/file-list.ts
|
||||
// appends editorId/compileGroup to the shared params object, then
|
||||
// one `files` entry per archived file -- a single file arrives as
|
||||
// a bare string, not a one-element array. enable_pdf_caching is
|
||||
// appended separately (see output-files.ts) whenever pdf caching is
|
||||
// enabled.
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
editorId: 'e58b482a-3c9e-4de1-9a05-d5e2f1c8f8f0',
|
||||
compileGroup: 'priority',
|
||||
files: 'output.pdf',
|
||||
enable_pdf_caching: 'true',
|
||||
}
|
||||
await ctx.CompileController.getOutputZipFromClsi(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
ctx.next
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reject the request', function (ctx) {
|
||||
ctx.next.should.not.have.been.called
|
||||
})
|
||||
|
||||
it('should proxy to the CLSI', function (ctx) {
|
||||
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('with multiple files in the query string', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
files: ['output.pdf', 'output.log'],
|
||||
}
|
||||
await ctx.CompileController.getOutputZipFromClsi(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
ctx.next
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reject the request', function (ctx) {
|
||||
ctx.next.should.not.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileForSubmissionFromClsi', function () {
|
||||
@@ -973,6 +1079,54 @@ describe('CompileController', function () {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject a buildId that does not match the hex-hyphen-hex shape', async function (ctx) {
|
||||
ctx.req.query.buildId = 'not-a-valid-build-id'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
name: 'InvalidRequestError',
|
||||
zodError: asZodError({
|
||||
origin: 'string',
|
||||
code: 'invalid_format',
|
||||
format: 'regex',
|
||||
pattern: '/^[0-9a-f]+-[0-9a-f]+$/',
|
||||
path: ['query', 'buildId'],
|
||||
message: 'invalid buildId',
|
||||
}),
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
|
||||
describe('when the schema is in log-only mode', function () {
|
||||
beforeEach(function () {
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
it('should still reject a non-numeric line via the manual guard', async function (ctx) {
|
||||
ctx.req.query.line = 'not-a-number'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
message: 'invalid line parameter',
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('should still reject a non-numeric column via the manual guard', async function (ctx) {
|
||||
ctx.req.query.column = 'not-a-number'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
message: 'invalid column parameter',
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxySyncPdf', function () {
|
||||
@@ -1023,6 +1177,64 @@ describe('CompileController', function () {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should reject a buildId that does not match the hex-hyphen-hex shape', async function (ctx) {
|
||||
ctx.req.query.buildId = 'not-a-valid-build-id'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
name: 'InvalidRequestError',
|
||||
zodError: asZodError({
|
||||
origin: 'string',
|
||||
code: 'invalid_format',
|
||||
format: 'regex',
|
||||
pattern: '/^[0-9a-f]+-[0-9a-f]+$/',
|
||||
path: ['query', 'buildId'],
|
||||
message: 'invalid buildId',
|
||||
}),
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
|
||||
describe('when the schema is in log-only mode', function () {
|
||||
beforeEach(function () {
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
it('should still reject a non-numeric page via the manual guard', async function (ctx) {
|
||||
ctx.req.query.page = 'not-a-number'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
message: 'invalid page parameter',
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('should still reject a non-numeric h via the manual guard', async function (ctx) {
|
||||
ctx.req.query.h = 'not-a-number'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
message: 'invalid h parameter',
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('should still reject a non-numeric v via the manual guard', async function (ctx) {
|
||||
ctx.req.query.v = 'not-a-number'
|
||||
ctx.next = sinon.stub()
|
||||
await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
message: 'invalid v parameter',
|
||||
})
|
||||
ctx.CompileManager.promises.syncTeX.should.have.been.calledOnce
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileFromClsi', function () {
|
||||
@@ -1169,6 +1381,89 @@ describe('CompileController', function () {
|
||||
expect(ctx.res.statusCode).to.equal(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a compileGroup query param', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
// buildFileList (see file-list.ts) always appends this to every
|
||||
// per-file download link it builds
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
compileGroup: 'priority',
|
||||
}
|
||||
await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('should not reject the request', function (ctx) {
|
||||
ctx.next.should.not.have.been.called
|
||||
})
|
||||
|
||||
it('should proxy to the CLSI', function (ctx) {
|
||||
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an enable_pdf_caching query param', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
// output-files.ts appends this to per-file download links whenever
|
||||
// pdf caching is enabled.
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
enable_pdf_caching: 'true',
|
||||
}
|
||||
await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
|
||||
it('should not reject the request', function (ctx) {
|
||||
ctx.next.should.not.have.been.called
|
||||
})
|
||||
|
||||
it('should proxy to the CLSI', function (ctx) {
|
||||
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an invalid enable_pdf_caching query param', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.query = {
|
||||
clsiserverid: ctx.clsiServerId,
|
||||
enable_pdf_caching: 'notabool',
|
||||
}
|
||||
ctx.next = sinon.stub()
|
||||
})
|
||||
|
||||
it('should reject the request', async function (ctx) {
|
||||
await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.should.have.been.calledWithMatch({
|
||||
name: 'InvalidRequestError',
|
||||
zodError: asZodError({
|
||||
code: 'invalid_value',
|
||||
expected: 'stringbool',
|
||||
values: [
|
||||
'true',
|
||||
'1',
|
||||
'yes',
|
||||
'on',
|
||||
'y',
|
||||
'enabled',
|
||||
'false',
|
||||
'0',
|
||||
'no',
|
||||
'off',
|
||||
'n',
|
||||
'disabled',
|
||||
],
|
||||
path: ['query', 'enable_pdf_caching'],
|
||||
message:
|
||||
'Invalid option: expected one of "true"|"1"|"yes"|"on"|"y"|"enabled"|"false"|"0"|"no"|"off"|"n"|"disabled"',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open a request to CLSI', async function (ctx) {
|
||||
await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
|
||||
ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAuxFiles', function () {
|
||||
|
||||
@@ -38,14 +38,15 @@ describe('FileStoreController', function () {
|
||||
|
||||
ctx.controller = (await import(MODULE_PATH)).default
|
||||
ctx.stream = {}
|
||||
ctx.projectId = '2k3j1lk3j21lk3j'
|
||||
ctx.fileId = '12321kklj1lk3jk12'
|
||||
// Project_id/File_id are validated as Mongo ObjectIds
|
||||
ctx.projectId = '507f191e810c19729de860ea'
|
||||
ctx.fileId = '507f191e810c19729de860eb'
|
||||
ctx.req = {
|
||||
params: {
|
||||
Project_id: ctx.projectId,
|
||||
File_id: ctx.fileId,
|
||||
},
|
||||
query: 'query string here',
|
||||
query: { foo: 'bar' },
|
||||
get(key) {
|
||||
return undefined
|
||||
},
|
||||
|
||||
@@ -45,4 +45,26 @@ describe('UrlHelper', function () {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCanonicalURL', function () {
|
||||
it('strips the query string and hash', function (ctx) {
|
||||
const req = {
|
||||
headers: { host: '127.0.0.1:3000' },
|
||||
originalUrl: '/blog/some-post?foo=bar#section',
|
||||
}
|
||||
expect(ctx.UrlHelper.getCanonicalURL(req)).to.equal(
|
||||
'http://127.0.0.1:3000/blog/some-post'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing slash', function (ctx) {
|
||||
const req = {
|
||||
headers: { host: '127.0.0.1:3000' },
|
||||
originalUrl: '/blog/some-post/',
|
||||
}
|
||||
expect(ctx.UrlHelper.getCanonicalURL(req)).to.equal(
|
||||
'http://127.0.0.1:3000/blog/some-post'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/InactiveData/InactiveProjectController'
|
||||
|
||||
describe('InactiveProjectController', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.InactiveProjectManager = {
|
||||
deactivateOldProjects: sinon.stub(),
|
||||
deactivateProject: sinon.stub(),
|
||||
}
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/InactiveData/InactiveProjectManager',
|
||||
() => ({
|
||||
default: ctx.InactiveProjectManager,
|
||||
})
|
||||
)
|
||||
ctx.controller = (await import(modulePath)).default
|
||||
ctx.req = new MockRequest(vi)
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.res.json = sinon.stub()
|
||||
ctx.res.sendStatus = sinon.stub()
|
||||
})
|
||||
|
||||
describe('deactivateOldProjects', function () {
|
||||
it('should call the manager with the parsed numeric values', function (ctx) {
|
||||
ctx.req.body = { numberOfProjectsToArchive: 5, ageOfProjects: 30 }
|
||||
ctx.InactiveProjectManager.deactivateOldProjects.callsFake(
|
||||
(limit, daysOld, cb) => cb(null, ['project-1'])
|
||||
)
|
||||
ctx.controller.deactivateOldProjects(ctx.req, ctx.res)
|
||||
ctx.InactiveProjectManager.deactivateOldProjects
|
||||
.calledWith(5, 30)
|
||||
.should.equal(true)
|
||||
ctx.res.json.calledWith(['project-1']).should.equal(true)
|
||||
})
|
||||
|
||||
it('should coerce numeric-looking strings sent as the body', function (ctx) {
|
||||
// some private-API callers post numbers as strings
|
||||
ctx.req.body = { numberOfProjectsToArchive: '5', ageOfProjects: '30' }
|
||||
ctx.InactiveProjectManager.deactivateOldProjects.callsFake(
|
||||
(limit, daysOld, cb) => cb(null, [])
|
||||
)
|
||||
ctx.controller.deactivateOldProjects(ctx.req, ctx.res)
|
||||
ctx.InactiveProjectManager.deactivateOldProjects
|
||||
.calledWith(5, 30)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should work without any body fields', function (ctx) {
|
||||
ctx.req.body = {}
|
||||
ctx.InactiveProjectManager.deactivateOldProjects.callsFake(
|
||||
(limit, daysOld, cb) => cb(null, [])
|
||||
)
|
||||
ctx.controller.deactivateOldProjects(ctx.req, ctx.res)
|
||||
ctx.InactiveProjectManager.deactivateOldProjects
|
||||
.calledWith(undefined, undefined)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return a 500 when the manager errors', function (ctx) {
|
||||
ctx.req.body = {}
|
||||
ctx.InactiveProjectManager.deactivateOldProjects.callsFake(
|
||||
(limit, daysOld, cb) => cb(new Error('failed'))
|
||||
)
|
||||
ctx.controller.deactivateOldProjects(ctx.req, ctx.res)
|
||||
ctx.res.sendStatus.calledWith(500).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deactivateProject', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// project_id is validated as a Mongo ObjectId
|
||||
ctx.projectId = '507f191e810c19729de860ea'
|
||||
ctx.req.params = { project_id: ctx.projectId }
|
||||
})
|
||||
|
||||
it('should call the manager and return 200', function (ctx) {
|
||||
ctx.InactiveProjectManager.deactivateProject.callsFake((id, cb) =>
|
||||
cb(null)
|
||||
)
|
||||
ctx.controller.deactivateProject(ctx.req, ctx.res)
|
||||
ctx.InactiveProjectManager.deactivateProject
|
||||
.calledWith(ctx.projectId)
|
||||
.should.equal(true)
|
||||
ctx.res.sendStatus.calledWith(200).should.equal(true)
|
||||
})
|
||||
|
||||
it('should return a 500 when the manager errors', function (ctx) {
|
||||
ctx.InactiveProjectManager.deactivateProject.callsFake((id, cb) =>
|
||||
cb(new Error('failed'))
|
||||
)
|
||||
ctx.controller.deactivateProject(ctx.req, ctx.res)
|
||||
ctx.res.sendStatus.calledWith(500).should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs'
|
||||
|
||||
@@ -13,6 +14,10 @@ describe('LinkedFilesController', function () {
|
||||
ctx.clock.restore()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.userId = 'user-id'
|
||||
ctx.Agent = {
|
||||
@@ -25,9 +30,11 @@ describe('LinkedFilesController', function () {
|
||||
// ObjectIds, so use well-formed values throughout.
|
||||
ctx.projectId = '507f1f77bcf86cd799439011'
|
||||
ctx.parentFolderId = '507f191e810c19729de860eb'
|
||||
ctx.provider = 'provider'
|
||||
// must be one of the real, schema-validated provider literals; 'url' is
|
||||
// the simplest shape (a single required `url` field in `data`).
|
||||
ctx.provider = 'url'
|
||||
ctx.fileName = 'linked-file-name'
|
||||
ctx.data = { customAgentData: 'foo' }
|
||||
ctx.data = { url: 'https://example.com/foo' }
|
||||
ctx.LinkedFilesHandler = {
|
||||
promises: {
|
||||
getFileById: sinon.stub(),
|
||||
@@ -140,7 +147,7 @@ describe('LinkedFilesController', function () {
|
||||
expect(ctx.Agent.promises.createLinkedFile).to.have.been.calledWith(
|
||||
ctx.projectId,
|
||||
{
|
||||
customAgentData: 'foo',
|
||||
url: 'https://example.com/foo',
|
||||
provider: ctx.provider,
|
||||
importedAt: ctx.fakeTime.toISOString(),
|
||||
},
|
||||
@@ -154,6 +161,43 @@ describe('LinkedFilesController', function () {
|
||||
ctx.LinkedFilesController.createLinkedFile(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a mendeley group_id containing a path separator without calling the agent', async function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.req.body.provider = 'mendeley'
|
||||
ctx.req.body.data = { group_id: 'abcd/../../etc' }
|
||||
await new Promise(resolve => {
|
||||
ctx.next = sinon.stub().callsFake(() => resolve())
|
||||
ctx.res = {
|
||||
json: () => resolve(),
|
||||
sendStatus: () => resolve(),
|
||||
}
|
||||
ctx.LinkedFilesController.createLinkedFile(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
expect(ctx.next).to.have.been.calledOnce
|
||||
expect(ctx.next.firstCall.args[0]?.name).to.equal('InvalidRequestError')
|
||||
expect(ctx.Agent.promises.createLinkedFile).to.not.have.been.called
|
||||
})
|
||||
|
||||
it('rejects a project_output_file build_id that is not in the hex-hyphen-hex shape without calling the agent', async function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.req.body.provider = 'project_output_file'
|
||||
ctx.req.body.data = {
|
||||
source_output_file_path: 'output.pdf',
|
||||
build_id: 'not-a-valid-build-id',
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
ctx.next = sinon.stub().callsFake(() => resolve())
|
||||
ctx.res = {
|
||||
json: () => resolve(),
|
||||
sendStatus: () => resolve(),
|
||||
}
|
||||
ctx.LinkedFilesController.createLinkedFile(ctx.req, ctx.res, ctx.next)
|
||||
})
|
||||
expect(ctx.next).to.have.been.calledOnce
|
||||
expect(ctx.next.firstCall.args[0]?.name).to.equal('InvalidRequestError')
|
||||
expect(ctx.Agent.promises.createLinkedFile).to.not.have.been.called
|
||||
})
|
||||
})
|
||||
describe('refreshLinkedFiles', function () {
|
||||
beforeEach(function (ctx) {
|
||||
@@ -211,6 +255,7 @@ describe('LinkedFilesController', function () {
|
||||
})
|
||||
|
||||
it('rejects invalid params without calling the agent', async function (ctx) {
|
||||
setReqValidationModeForTests('enforce')
|
||||
ctx.req.params.file_id = 'not-an-object-id'
|
||||
await new Promise(resolve => {
|
||||
ctx.next = sinon.stub().callsFake(() => resolve())
|
||||
|
||||
@@ -121,6 +121,10 @@ describe('PasswordResetController', function () {
|
||||
})
|
||||
|
||||
describe('requestReset', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.body = { email: ctx.req.body.email }
|
||||
})
|
||||
|
||||
it('should tell the handler to process that email', async function (ctx) {
|
||||
ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
|
||||
'primary'
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/ServerAdmin/AdminController.mjs'
|
||||
|
||||
describe('AdminController', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.Settings = {}
|
||||
ctx.TpdsUpdateSender = { pollDropboxForUser: sinon.stub() }
|
||||
ctx.TpdsProjectFlusher = { flushProjectToTpds: sinon.stub() }
|
||||
ctx.EditorRealTimeController = { emitToAll: sinon.stub() }
|
||||
ctx.SystemMessageManager = {
|
||||
createMessage: sinon.stub(),
|
||||
clearMessages: sinon.stub(),
|
||||
promises: {
|
||||
getMessagesFromDB: sinon.stub().resolves([]),
|
||||
},
|
||||
}
|
||||
ctx.ProjectGetter = {
|
||||
promises: { findAllDebugProjects: sinon.stub().resolves([]) },
|
||||
}
|
||||
ctx.Modules = {
|
||||
promises: { hooks: { fire: sinon.stub().resolves([null]) } },
|
||||
}
|
||||
ctx.Features = { hasFeature: sinon.stub().returns(false) }
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings }))
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateSender',
|
||||
() => ({ default: ctx.TpdsUpdateSender })
|
||||
)
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher',
|
||||
() => ({ default: ctx.TpdsProjectFlusher })
|
||||
)
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Editor/EditorRealTimeController',
|
||||
() => ({ default: ctx.EditorRealTimeController })
|
||||
)
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/SystemMessages/SystemMessageManager',
|
||||
() => ({ default: ctx.SystemMessageManager })
|
||||
)
|
||||
vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
|
||||
default: ctx.ProjectGetter,
|
||||
}))
|
||||
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
|
||||
default: ctx.Modules,
|
||||
}))
|
||||
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
|
||||
default: ctx.Features,
|
||||
}))
|
||||
|
||||
ctx.controller = (await import(modulePath)).default
|
||||
ctx.req = new MockRequest(vi)
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.res.redirect = sinon.stub()
|
||||
ctx.res.sendStatus = sinon.stub()
|
||||
ctx.res.render = sinon.stub()
|
||||
ctx.next = sinon.stub()
|
||||
})
|
||||
|
||||
describe('index', function () {
|
||||
it('should render the admin page', async function (ctx) {
|
||||
await ctx.controller.index(ctx.req, ctx.res, ctx.next)
|
||||
ctx.res.render.calledWith('admin/index').should.equal(true)
|
||||
})
|
||||
|
||||
it('should include debug projects when saas is enabled', async function (ctx) {
|
||||
ctx.Features.hasFeature.returns(true)
|
||||
await ctx.controller.index(ctx.req, ctx.res, ctx.next)
|
||||
ctx.ProjectGetter.promises.findAllDebugProjects.called.should.equal(true)
|
||||
ctx.res.render
|
||||
.calledWithMatch('admin/index', {
|
||||
debugProjects: [],
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnectAllUsers', function () {
|
||||
it('should use the default delay when none is given', function (ctx) {
|
||||
ctx.req.query = {}
|
||||
ctx.controller.disconnectAllUsers(ctx.req, ctx.res)
|
||||
ctx.EditorRealTimeController.emitToAll
|
||||
.calledWith('forceDisconnect', sinon.match.string, 10)
|
||||
.should.equal(true)
|
||||
ctx.res.redirect.calledWith('/admin#open-close-editor').should.equal(true)
|
||||
})
|
||||
|
||||
it('should use the given delay', function (ctx) {
|
||||
ctx.req.query = { delay: '5000' }
|
||||
ctx.controller.disconnectAllUsers(ctx.req, ctx.res)
|
||||
ctx.EditorRealTimeController.emitToAll
|
||||
.calledWith('forceDisconnect', sinon.match.string, 5000)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openEditor', function () {
|
||||
it('should open the editor and redirect', function (ctx) {
|
||||
ctx.controller.openEditor(ctx.req, ctx.res)
|
||||
ctx.Settings.editorIsOpen.should.equal(true)
|
||||
ctx.res.redirect.calledWith('/admin#open-close-editor').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeEditor', function () {
|
||||
it('should close the editor and redirect', function (ctx) {
|
||||
ctx.req.body = {}
|
||||
ctx.controller.closeEditor(ctx.req, ctx.res)
|
||||
Boolean(ctx.Settings.editorIsOpen).should.equal(false)
|
||||
ctx.res.redirect.calledWith('/admin#open-close-editor').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushProjectToTpds', function () {
|
||||
beforeEach(function (ctx) {
|
||||
// project_id is validated as a Mongo ObjectId
|
||||
ctx.projectId = '507f191e810c19729de860ea'
|
||||
ctx.req.body = { project_id: ctx.projectId }
|
||||
})
|
||||
|
||||
it('should flush the project and return 200', function (ctx) {
|
||||
ctx.TpdsProjectFlusher.flushProjectToTpds.callsFake((id, cb) => cb())
|
||||
ctx.controller.flushProjectToTpds(ctx.req, ctx.res, ctx.next)
|
||||
ctx.TpdsProjectFlusher.flushProjectToTpds
|
||||
.calledWith(ctx.projectId)
|
||||
.should.equal(true)
|
||||
ctx.res.sendStatus.calledWith(200).should.equal(true)
|
||||
})
|
||||
|
||||
it('should call next with an error on failure', function (ctx) {
|
||||
const error = new Error('failed')
|
||||
ctx.TpdsProjectFlusher.flushProjectToTpds.callsFake((id, cb) => cb(error))
|
||||
ctx.controller.flushProjectToTpds(ctx.req, ctx.res, ctx.next)
|
||||
ctx.next.calledWith(error).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollDropboxForUser', function () {
|
||||
it('should poll dropbox for the user', function (ctx) {
|
||||
// user_id is validated as a Mongo ObjectId
|
||||
ctx.userId = '507f191e810c19729de860eb'
|
||||
ctx.req.body = { user_id: ctx.userId }
|
||||
ctx.TpdsUpdateSender.pollDropboxForUser.callsFake((id, cb) => cb())
|
||||
ctx.controller.pollDropboxForUser(ctx.req, ctx.res)
|
||||
ctx.TpdsUpdateSender.pollDropboxForUser
|
||||
.calledWith(ctx.userId)
|
||||
.should.equal(true)
|
||||
ctx.res.sendStatus.calledWith(200).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMessage', function () {
|
||||
it('should create the message and redirect', function (ctx) {
|
||||
ctx.req.body = { content: 'a system message' }
|
||||
ctx.SystemMessageManager.createMessage.callsFake((content, cb) => cb())
|
||||
ctx.controller.createMessage(ctx.req, ctx.res, ctx.next)
|
||||
ctx.SystemMessageManager.createMessage
|
||||
.calledWith('a system message')
|
||||
.should.equal(true)
|
||||
ctx.res.redirect.calledWith('/admin#system-messages').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMessages', function () {
|
||||
it('should clear messages and redirect', function (ctx) {
|
||||
ctx.SystemMessageManager.clearMessages.callsFake(cb => cb())
|
||||
ctx.controller.clearMessages(ctx.req, ctx.res, ctx.next)
|
||||
ctx.res.redirect.calledWith('/admin#system-messages').should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -543,6 +543,57 @@ describe('SplitTestHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('malformed query override values', function () {
|
||||
describe('getAssignment', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.AnalyticsManager.getIdsFromSession.returns({
|
||||
userId: 'abc123abc123',
|
||||
})
|
||||
ctx.req.query = { 'active-test': { nested: 'oops' } }
|
||||
})
|
||||
|
||||
it('falls through to the computed assignment', async function (ctx) {
|
||||
const { variant } = await ctx.SplitTestHandler.promises.getAssignment(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
'active-test'
|
||||
)
|
||||
expect(variant).to.equal('variant-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUserBeenAssignedToVariant', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.user = {
|
||||
_id: new ObjectId(),
|
||||
analyticsId: 'analytics-id',
|
||||
splitTests: {
|
||||
'active-test': [
|
||||
{
|
||||
variantName: 'variant-1',
|
||||
versionNumber: 2,
|
||||
assignedAt: 'active-test-assigned-at',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
|
||||
ctx.req.query = { 'active-test': { nested: 'oops' } }
|
||||
})
|
||||
|
||||
it('still resolves the recorded assignment', async function (ctx) {
|
||||
const assigned =
|
||||
await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
|
||||
ctx.req,
|
||||
ctx.user._id,
|
||||
'active-test',
|
||||
'variant-1'
|
||||
)
|
||||
expect(assigned).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('variant user limits', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.AnalyticsManager.getIdsFromSession.returns({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import {
|
||||
InvalidParamsError,
|
||||
InvalidRequestError,
|
||||
setReqValidationModeForTests,
|
||||
} from '@overleaf/validation-tools'
|
||||
import MockRequest from '../helpers/MockRequest.mjs'
|
||||
import MockResponse from '../helpers/MockResponse.mjs'
|
||||
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
|
||||
@@ -277,6 +282,10 @@ describe('TokenAccessController', function () {
|
||||
ctx.TokenAccessController = (await import(MODULE_PATH)).default
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('grantTokenAccessReadAndWrite', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
|
||||
@@ -1029,7 +1038,7 @@ describe('TokenAccessController', function () {
|
||||
|
||||
describe('ensureUserCanUseSharingUpdatesConsentPage', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.project._id }
|
||||
ctx.req.params = { Project_id: ctx.project._id.toString() }
|
||||
})
|
||||
|
||||
describe('when not in link sharing changes test', function () {
|
||||
@@ -1082,10 +1091,10 @@ describe('TokenAccessController', function () {
|
||||
expect(
|
||||
ctx.CollaboratorsGetter.promises
|
||||
.isUserInvitedReadWriteMemberOfProject
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id)
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id.toString())
|
||||
expect(
|
||||
ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id)
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id.toString())
|
||||
expect(ctx.next).to.have.been.calledOnce
|
||||
expect(ctx.next.firstCall.args[0]).to.not.exist
|
||||
})
|
||||
@@ -1147,7 +1156,7 @@ describe('TokenAccessController', function () {
|
||||
|
||||
describe('moveReadWriteToCollaborators', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.project._id }
|
||||
ctx.req.params = { Project_id: ctx.project._id.toString() }
|
||||
})
|
||||
|
||||
describe('when there are collaborator slots available', function () {
|
||||
@@ -1175,11 +1184,11 @@ describe('TokenAccessController', function () {
|
||||
it('sets the privilege level to read and write for the invited viewer', function (ctx) {
|
||||
expect(
|
||||
ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id)
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id.toString())
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.addUserIdToProject
|
||||
).to.have.been.calledWith(
|
||||
ctx.project._id,
|
||||
ctx.project._id.toString(),
|
||||
undefined,
|
||||
ctx.user._id,
|
||||
PrivilegeLevels.READ_AND_WRITE
|
||||
@@ -1214,11 +1223,11 @@ describe('TokenAccessController', function () {
|
||||
it('sets the privilege level to read only for the invited viewer (pendingEditor)', function (ctx) {
|
||||
expect(
|
||||
ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id)
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id.toString())
|
||||
expect(
|
||||
ctx.CollaboratorsHandler.promises.addUserIdToProject
|
||||
).to.have.been.calledWith(
|
||||
ctx.project._id,
|
||||
ctx.project._id.toString(),
|
||||
undefined,
|
||||
ctx.user._id,
|
||||
PrivilegeLevels.READ_ONLY,
|
||||
@@ -1232,7 +1241,7 @@ describe('TokenAccessController', function () {
|
||||
|
||||
describe('moveReadWriteToReadOnly', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.req.params = { Project_id: ctx.project._id }
|
||||
ctx.req.params = { Project_id: ctx.project._id.toString() }
|
||||
})
|
||||
|
||||
describe('previously joined token access user moving to anonymous viewer', function () {
|
||||
@@ -1250,7 +1259,7 @@ describe('TokenAccessController', function () {
|
||||
it('removes them from read write token access refs and adds them to read only token access refs', function (ctx) {
|
||||
expect(
|
||||
ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id)
|
||||
).to.have.been.calledWith(ctx.user._id, ctx.project._id.toString())
|
||||
expect(ctx.res.sendStatus).toHaveBeenCalledWith(204)
|
||||
})
|
||||
|
||||
@@ -1258,7 +1267,7 @@ describe('TokenAccessController', function () {
|
||||
expect(
|
||||
ctx.ProjectAuditLogHandler.promises.addEntry
|
||||
).to.have.been.calledWith(
|
||||
ctx.project._id,
|
||||
ctx.project._id.toString(),
|
||||
'readonly-via-sharing-updates',
|
||||
ctx.user._id,
|
||||
ctx.req.ip
|
||||
@@ -1266,4 +1275,96 @@ describe('TokenAccessController', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('request validation', function () {
|
||||
beforeEach(function () {
|
||||
setReqValidationModeForTests('enforce')
|
||||
})
|
||||
|
||||
it('rejects a grant read-and-write request body with an unrecognized field', async function (ctx) {
|
||||
ctx.req.params = { token: ctx.token }
|
||||
ctx.req.body = { confirmedByUser: true, extraField: 'nope' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.grantTokenAccessReadAndWrite(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidRequestError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a grant read-only request body with an unrecognized field', async function (ctx) {
|
||||
ctx.req.params = { token: ctx.token }
|
||||
ctx.req.body = { confirmedByUser: true, extraField: 'nope' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.grantTokenAccessReadOnly(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidRequestError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed Project_id when checking sharing-updates consent eligibility', async function (ctx) {
|
||||
ctx.req.params = { Project_id: 'not-an-object-id' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidParamsError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed Project_id on the sharing-updates consent page', async function (ctx) {
|
||||
ctx.req.params = { Project_id: 'not-an-object-id' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.sharingUpdatesConsent(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidParamsError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed Project_id when moving to collaborators', async function (ctx) {
|
||||
ctx.req.params = { Project_id: 'not-an-object-id' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.moveReadWriteToCollaborators(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidParamsError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed Project_id when moving to read-only', async function (ctx) {
|
||||
ctx.req.params = { Project_id: 'not-an-object-id' }
|
||||
await new Promise(resolve => {
|
||||
ctx.TokenAccessController.moveReadWriteToReadOnly(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
err => {
|
||||
expect(err).to.be.instanceof(InvalidParamsError)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user