mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-24 01:29:35 +02:00
* [clsi] initial implementation of compile from history * [clsi] copy changes * [saas-e2e] extend test case with nested folder * [saas-e2e] add test case for tracked changes * [web] fix accumulating changes from multiple chunks * [web] optimize size check for compile request payload * [clsi] deduplicate globalBlobs * [clsi] add validation for request body details * [clsi] add metrics for compile from history * [clsi] download binary files concurrently * [clsi] skip download of empty file blob * [clsi] break down e2e compile time metric by compileFromHistory GitOrigin-RevId: 0dadef93e89d8a172c35cb130a1042d9d1bec42a
504 lines
14 KiB
JavaScript
504 lines
14 KiB
JavaScript
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
|
import sinon from 'sinon'
|
|
import tk from 'timekeeper'
|
|
import path from 'node:path'
|
|
|
|
const modulePath = path.join(
|
|
import.meta.dirname,
|
|
'../../../app/js/RequestParser'
|
|
)
|
|
|
|
describe('RequestParser', () => {
|
|
beforeEach(async ctx => {
|
|
tk.freeze()
|
|
ctx.callback = sinon.stub()
|
|
ctx.validResource = {
|
|
path: 'main.tex',
|
|
date: '12:00 01/02/03',
|
|
content: 'Hello world',
|
|
}
|
|
ctx.validRequest = {
|
|
compile: {
|
|
token: 'token-123',
|
|
options: {
|
|
imageName: 'basicImageName/here:2017-1',
|
|
compiler: 'pdflatex',
|
|
timeout: 42,
|
|
},
|
|
resources: [],
|
|
},
|
|
}
|
|
|
|
vi.doMock('@overleaf/settings', () => ({
|
|
default: (ctx.settings = {}),
|
|
}))
|
|
|
|
vi.doMock('../../../app/js/OutputCacheManager', () => ({
|
|
default: { BUILD_REGEX: /^[0-9a-f]+-[0-9a-f]+$/ },
|
|
}))
|
|
|
|
ctx.RequestParser = (await import(modulePath)).default
|
|
})
|
|
|
|
afterEach(() => {
|
|
tk.reset()
|
|
})
|
|
|
|
describe('without a top level object', () => {
|
|
beforeEach(ctx => {
|
|
ctx.RequestParser.parse([], ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
expect(ctx.callback).to.have.been.called
|
|
expect(ctx.callback.args[0][0].message).to.equal(
|
|
'top level object should have a compile attribute'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('without a compile attribute', () => {
|
|
beforeEach(ctx => {
|
|
ctx.RequestParser.parse({}, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
expect(ctx.callback).to.have.been.called
|
|
expect(ctx.callback.args[0][0].message).to.equal(
|
|
'top level object should have a compile attribute'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('without a valid compiler', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.options.compiler = 'not-a-compiler'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message:
|
|
'compiler attribute should be one of: pdflatex, latex, xelatex, lualatex',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('without a compiler specified', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
delete ctx.validRequest.compile.options.compiler
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the compiler to pdflatex by default', ctx => {
|
|
ctx.data.compiler.should.equal('pdflatex')
|
|
})
|
|
})
|
|
|
|
describe('with imageName set', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the imageName', ctx => {
|
|
ctx.data.imageName.should.equal('basicImageName/here:2017-1')
|
|
})
|
|
})
|
|
|
|
describe('when image restrictions are present', () => {
|
|
beforeEach(ctx => {
|
|
ctx.settings.clsi = { docker: {} }
|
|
ctx.settings.clsi.docker.allowedImages = [
|
|
'repo/name:tag1',
|
|
'repo/name:tag2',
|
|
]
|
|
})
|
|
|
|
describe('with imageName set to something invalid', () => {
|
|
beforeEach(ctx => {
|
|
const request = ctx.validRequest
|
|
request.compile.options.imageName = 'something/different:latest'
|
|
ctx.RequestParser.parse(request, (error, data) => {
|
|
ctx.error = error
|
|
ctx.data = data
|
|
})
|
|
})
|
|
|
|
it('should throw an error for imageName', ctx => {
|
|
expect(String(ctx.error)).to.include(
|
|
'imageName attribute should be one of'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('with imageName set to something valid', () => {
|
|
beforeEach(ctx => {
|
|
const request = ctx.validRequest
|
|
request.compile.options.imageName = 'repo/name:tag1'
|
|
ctx.RequestParser.parse(request, (error, data) => {
|
|
ctx.error = error
|
|
ctx.data = data
|
|
})
|
|
})
|
|
|
|
it('should set the imageName', ctx => {
|
|
ctx.data.imageName.should.equal('repo/name:tag1')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('with flags set', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.validRequest.compile.options.flags = ['-file-line-error']
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the flags attribute', ctx => {
|
|
expect(ctx.data.flags).to.deep.equal(['-file-line-error'])
|
|
})
|
|
})
|
|
|
|
describe('with flags not specified', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('it should have an empty flags list', ctx => {
|
|
expect(ctx.data.flags).to.deep.equal([])
|
|
})
|
|
})
|
|
|
|
describe('without a timeout specified', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
delete ctx.validRequest.compile.options.timeout
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the timeout to MAX_TIMEOUT', ctx => {
|
|
ctx.data.timeout.should.equal(ctx.RequestParser.MAX_TIMEOUT * 1000)
|
|
})
|
|
})
|
|
|
|
describe('with a timeout larger than the maximum', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.validRequest.compile.options.timeout =
|
|
ctx.RequestParser.MAX_TIMEOUT + 1
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the timeout to MAX_TIMEOUT', ctx => {
|
|
ctx.data.timeout.should.equal(ctx.RequestParser.MAX_TIMEOUT * 1000)
|
|
})
|
|
})
|
|
|
|
describe('with a timeout', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should set the timeout (in milliseconds)', ctx => {
|
|
ctx.data.timeout.should.equal(
|
|
ctx.validRequest.compile.options.timeout * 1000
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('with a resource without a path', () => {
|
|
beforeEach(ctx => {
|
|
delete ctx.validResource.path
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message: 'all resources should have a path attribute',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a resource with a path', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.path = ctx.path = 'test.tex'
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return the path in the parsed response', ctx => {
|
|
ctx.data.resources[0].path.should.equal(ctx.path)
|
|
})
|
|
})
|
|
|
|
describe('with a resource with a malformed modified date', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.modified = 'not-a-date'
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message:
|
|
'resource modified date could not be understood: ' +
|
|
ctx.validResource.modified,
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a valid buildId', () => {
|
|
beforeEach(async ctx => {
|
|
await new Promise((resolve, reject) => {
|
|
ctx.validRequest.compile.options.buildId =
|
|
'195a4869176-a4ad60bee7bf35e4'
|
|
ctx.RequestParser.parse(ctx.validRequest, (error, data) => {
|
|
if (error) return reject(error)
|
|
ctx.data = data
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.data.buildId.should.equal('195a4869176-a4ad60bee7bf35e4')
|
|
})
|
|
})
|
|
|
|
describe('with a bad buildId', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.options.buildId = 'foo/bar'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message:
|
|
'buildId attribute does not match regex /^[0-9a-f]+-[0-9a-f]+$/',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a resource with a valid date', () => {
|
|
beforeEach(ctx => {
|
|
ctx.date = '12:00 01/02/03'
|
|
ctx.validResource.modified = ctx.date
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return the date as a Javascript Date object', ctx => {
|
|
;(ctx.data.resources[0].modified instanceof Date).should.equal(true)
|
|
ctx.data.resources[0].modified
|
|
.getTime()
|
|
.should.equal(Date.parse(ctx.date))
|
|
})
|
|
})
|
|
|
|
describe('with a resource without either a content or URL attribute', () => {
|
|
beforeEach(ctx => {
|
|
delete ctx.validResource.url
|
|
delete ctx.validResource.content
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message:
|
|
'all resources should have either a url or content attribute',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a resource where the content is not a string', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.content = []
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({ message: 'content attribute should be a string' })
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a resource where the url is not a string', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.url = []
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({ message: 'url attribute should be a string' })
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a resource with a url', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.url = ctx.url = 'www.example.com'
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return the url in the parsed response', ctx => {
|
|
ctx.data.resources[0].url.should.equal(ctx.url)
|
|
})
|
|
})
|
|
|
|
describe('with a resource with a content attribute', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validResource.content = ctx.content = 'Hello world'
|
|
ctx.validRequest.compile.resources.push(ctx.validResource)
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return the content in the parsed response', ctx => {
|
|
ctx.data.resources[0].content.should.equal(ctx.content)
|
|
})
|
|
})
|
|
|
|
describe('without a root resource path', () => {
|
|
beforeEach(ctx => {
|
|
delete ctx.validRequest.compile.rootResourcePath
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it("should set the root resource path to 'main.tex' by default", ctx => {
|
|
ctx.data.rootResourcePath.should.equal('main.tex')
|
|
})
|
|
})
|
|
|
|
describe('with a root resource path', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.rootResourcePath = ctx.path = 'test.tex'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return the root resource path in the parsed response', ctx => {
|
|
ctx.data.rootResourcePath.should.equal(ctx.path)
|
|
})
|
|
})
|
|
|
|
describe('with a root resource path that is not a string', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.rootResourcePath = []
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message: 'rootResourcePath attribute should be a string',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a root resource path that has a relative path', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.rootResourcePath = 'foo/../../bar.tex'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({ message: 'relative path in root resource' })
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with a root resource path that has unescaped + relative path', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.rootResourcePath = 'foo/../bar.tex'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({ message: 'relative path in root resource' })
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
|
|
describe('with an unknown syncType', () => {
|
|
beforeEach(ctx => {
|
|
ctx.validRequest.compile.options.syncType = 'unexpected'
|
|
ctx.RequestParser.parse(ctx.validRequest, ctx.callback)
|
|
ctx.data = ctx.callback.args[0][1]
|
|
})
|
|
|
|
it('should return an error', ctx => {
|
|
ctx.callback
|
|
.calledWithMatch({
|
|
message:
|
|
'syncType attribute should be one of: full, incremental, history-full, history-incremental',
|
|
})
|
|
.should.equal(true)
|
|
})
|
|
})
|
|
})
|