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:
Jakob Ackermann
2026-09-17 11:55:40 +00:00
committed by Copybot
parent 55c23b6812
commit de0d1a7226
58 changed files with 3624 additions and 262 deletions
@@ -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()
}
)
})
})
})
})