mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[docstore] adopt strict zod types and extend test coverage (#36076)
GitOrigin-RevId: b9f1b965dbdb763ce541dabbe89300ce6d044217
This commit is contained in:
@@ -28,22 +28,6 @@ app.use(Metrics.http.monitor(logger))
|
|||||||
|
|
||||||
Metrics.injectMetricsRoute(app)
|
Metrics.injectMetricsRoute(app)
|
||||||
|
|
||||||
app.param('project_id', function (req, res, next, projectId) {
|
|
||||||
if (projectId?.match(/^[0-9a-f]{24}$/)) {
|
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
next(new Error('invalid project id'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.param('doc_id', function (req, res, next, docId) {
|
|
||||||
if (docId?.match(/^[0-9a-f]{24}$/)) {
|
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
next(new Error('invalid doc id'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/project/:project_id/doc-deleted', HttpController.getAllDeletedDocs)
|
app.get('/project/:project_id/doc-deleted', HttpController.getAllDeletedDocs)
|
||||||
app.get('/project/:project_id/doc', HttpController.getAllDocs)
|
app.get('/project/:project_id/doc', HttpController.getAllDocs)
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
@@ -5,21 +5,114 @@ import HealthChecker from './HealthChecker.js'
|
|||||||
import Errors from './Errors.js'
|
import Errors from './Errors.js'
|
||||||
import Settings from '@overleaf/settings'
|
import Settings from '@overleaf/settings'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
import { parseReq, z } from '@overleaf/validation-tools'
|
import { parseReq, z, zz } from '@overleaf/validation-tools'
|
||||||
|
import * as schemas from './schemas.js'
|
||||||
|
|
||||||
|
const projectParamsSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
project_id: zz.objectId(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const docParamsSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
project_id: zz.objectId(),
|
||||||
|
doc_id: zz.objectId(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const getDocSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
project_id: zz.objectId(),
|
||||||
|
doc_id: zz.objectId(),
|
||||||
|
}),
|
||||||
|
query: z.strictObject({
|
||||||
|
include_deleted: z.stringbool().default(false),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rollout-temporary fallback (loosened primary schema; no zod validation
|
||||||
|
// existed for this route on main); delete when this route's
|
||||||
|
// REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
const getDocFallbackSchema = z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: z.string(),
|
||||||
|
doc_id: z.string(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
include_deleted: z.stringbool().default(false),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectHasRangesSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
project_id: zz.objectId(),
|
||||||
|
}),
|
||||||
|
query: z.strictObject({
|
||||||
|
useSecondary: z.stringbool().default(false),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rollout-temporary fallback (loosened primary schema; no zod validation
|
||||||
|
// existed for this route on main); delete when this route's
|
||||||
|
// REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
|
const projectHasRangesFallbackSchema = z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: z.string(),
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
useSecondary: z.stringbool().default(false),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateDocSchema = z.object({
|
||||||
|
params: z.strictObject({
|
||||||
|
project_id: zz.objectId(),
|
||||||
|
doc_id: zz.objectId(),
|
||||||
|
}),
|
||||||
|
body: z.strictObject({
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
version: z.number(),
|
||||||
|
ranges: schemas.ranges,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
const patchDocSchema = z.object({
|
const patchDocSchema = z.object({
|
||||||
body: z
|
params: z.strictObject({
|
||||||
.object({
|
project_id: zz.objectId(),
|
||||||
deleted: z.literal(true),
|
doc_id: zz.objectId(),
|
||||||
deletedAt: z.coerce.date(),
|
}),
|
||||||
name: z.string(),
|
body: z.strictObject({
|
||||||
})
|
deleted: z.literal(true),
|
||||||
.strict(),
|
deletedAt: z.coerce.date(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rollout-temporary fallback (pre-refinement schema from main); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed. Only
|
||||||
|
// `body` was validated by parseReq() on main -- `params` were read directly
|
||||||
|
// off `req.params` unvalidated, so this keeps them as bare strings rather
|
||||||
|
// than reconstructing a param check that never existed.
|
||||||
|
const patchDocFallbackSchema = z.object({
|
||||||
|
params: z.object({
|
||||||
|
project_id: z.string(),
|
||||||
|
doc_id: z.string(),
|
||||||
|
}),
|
||||||
|
body: z.strictObject({
|
||||||
|
deleted: z.literal(true),
|
||||||
|
deletedAt: z.coerce.date(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getDoc(req, res) {
|
async function getDoc(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params, query } = parseReq(req, getDocSchema, {
|
||||||
const includeDeleted = req.query.include_deleted === 'true'
|
logOnly: true,
|
||||||
|
fallbackSchema: getDocFallbackSchema,
|
||||||
|
})
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
|
const includeDeleted = query.include_deleted
|
||||||
logger.debug({ projectId, docId }, 'getting doc')
|
logger.debug({ projectId, docId }, 'getting doc')
|
||||||
const doc = await DocManager.getFullDoc(projectId, docId)
|
const doc = await DocManager.getFullDoc(projectId, docId)
|
||||||
logger.debug({ docId, projectId }, 'got doc')
|
logger.debug({ docId, projectId }, 'got doc')
|
||||||
@@ -31,7 +124,8 @@ async function getDoc(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function peekDoc(req, res) {
|
async function peekDoc(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params } = parseReq(req, docParamsSchema, { logOnly: true })
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
logger.debug({ projectId, docId }, 'peeking doc')
|
logger.debug({ projectId, docId }, 'peeking doc')
|
||||||
const doc = await DocManager.peekDoc(projectId, docId, {
|
const doc = await DocManager.peekDoc(projectId, docId, {
|
||||||
deleted: true,
|
deleted: true,
|
||||||
@@ -46,13 +140,15 @@ async function peekDoc(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function isDocDeleted(req, res) {
|
async function isDocDeleted(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params } = parseReq(req, docParamsSchema, { logOnly: true })
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
const deleted = await DocManager.isDocDeleted(projectId, docId)
|
const deleted = await DocManager.isDocDeleted(projectId, docId)
|
||||||
res.json({ deleted })
|
res.json({ deleted })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRawDoc(req, res) {
|
async function getRawDoc(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params } = parseReq(req, docParamsSchema, { logOnly: true })
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
logger.debug({ projectId, docId }, 'getting raw doc')
|
logger.debug({ projectId, docId }, 'getting raw doc')
|
||||||
const content = await DocManager.getDocLines(projectId, docId)
|
const content = await DocManager.getDocLines(projectId, docId)
|
||||||
res.setHeader('content-type', 'text/plain')
|
res.setHeader('content-type', 'text/plain')
|
||||||
@@ -60,7 +156,8 @@ async function getRawDoc(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAllDocs(req, res) {
|
async function getAllDocs(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'getting all docs')
|
logger.debug({ projectId }, 'getting all docs')
|
||||||
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
||||||
lines: true,
|
lines: true,
|
||||||
@@ -77,7 +174,8 @@ async function getAllDocs(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAllDocsWithRanges(req, res) {
|
async function getAllDocsWithRanges(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'getting all docs with ranges')
|
logger.debug({ projectId }, 'getting all docs with ranges')
|
||||||
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
||||||
lines: true,
|
lines: true,
|
||||||
@@ -95,13 +193,15 @@ async function getAllDocsWithRanges(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAllDocVersions(req, res) {
|
async function getAllDocVersions(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
const docs = await DocManager.getAllDocVersions(projectId)
|
const docs = await DocManager.getAllDocVersions(projectId)
|
||||||
res.json(docs)
|
res.json(docs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllDeletedDocs(req, res) {
|
async function getAllDeletedDocs(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'getting all deleted docs')
|
logger.debug({ projectId }, 'getting all deleted docs')
|
||||||
const docs = await DocManager.getAllDeletedDocs(projectId, {
|
const docs = await DocManager.getAllDeletedDocs(projectId, {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -117,7 +217,8 @@ async function getAllDeletedDocs(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAllRanges(req, res) {
|
async function getAllRanges(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'getting all ranges')
|
logger.debug({ projectId }, 'getting all ranges')
|
||||||
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
|
||||||
ranges: true,
|
ranges: true,
|
||||||
@@ -126,20 +227,26 @@ async function getAllRanges(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getCommentThreadIds(req, res) {
|
async function getCommentThreadIds(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
const threadIds = await DocManager.getCommentThreadIds(projectId)
|
const threadIds = await DocManager.getCommentThreadIds(projectId)
|
||||||
res.json(threadIds)
|
res.json(threadIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTrackedChangesUserIds(req, res) {
|
async function getTrackedChangesUserIds(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
const userIds = await DocManager.getTrackedChangesUserIds(projectId)
|
const userIds = await DocManager.getTrackedChangesUserIds(projectId)
|
||||||
res.json(userIds)
|
res.json(userIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function projectHasRanges(req, res) {
|
async function projectHasRanges(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params, query } = parseReq(req, projectHasRangesSchema, {
|
||||||
const useSecondary = req.query.useSecondary === 'true'
|
logOnly: true,
|
||||||
|
fallbackSchema: projectHasRangesFallbackSchema,
|
||||||
|
})
|
||||||
|
const { project_id: projectId } = params
|
||||||
|
const { useSecondary } = query
|
||||||
const projectHasRanges = await DocManager.projectHasRanges(
|
const projectHasRanges = await DocManager.projectHasRanges(
|
||||||
projectId,
|
projectId,
|
||||||
useSecondary
|
useSecondary
|
||||||
@@ -148,11 +255,12 @@ async function projectHasRanges(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateDoc(req, res) {
|
async function updateDoc(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params, body } = parseReq(req, updateDocSchema, { logOnly: true })
|
||||||
const lines = req.body?.lines
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
const version = req.body?.version
|
const { lines, version, ranges } = body
|
||||||
const ranges = req.body?.ranges
|
|
||||||
|
|
||||||
|
// Rollout-temporary fallback (validation on the fallback schema); delete
|
||||||
|
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
|
||||||
if (lines == null || !(lines instanceof Array)) {
|
if (lines == null || !(lines instanceof Array)) {
|
||||||
logger.error({ projectId, docId }, 'no doc lines provided')
|
logger.error({ projectId, docId }, 'no doc lines provided')
|
||||||
res.sendStatus(400) // Bad Request
|
res.sendStatus(400) // Bad Request
|
||||||
@@ -193,8 +301,10 @@ async function updateDoc(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function patchDoc(req, res) {
|
async function patchDoc(req, res) {
|
||||||
const { body: meta } = parseReq(req, patchDocSchema)
|
const { params, body: meta } = parseReq(req, patchDocSchema, {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
fallbackSchema: patchDocFallbackSchema,
|
||||||
|
})
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
logger.debug({ projectId, docId }, 'patching doc')
|
logger.debug({ projectId, docId }, 'patching doc')
|
||||||
await DocManager.patchDoc(projectId, docId, meta)
|
await DocManager.patchDoc(projectId, docId, meta)
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
@@ -227,21 +337,24 @@ function _buildDocsArrayView(projectId, docs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveAllDocs(req, res) {
|
async function archiveAllDocs(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'archiving all docs')
|
logger.debug({ projectId }, 'archiving all docs')
|
||||||
await DocArchive.archiveAllDocs(projectId)
|
await DocArchive.archiveAllDocs(projectId)
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveDoc(req, res) {
|
async function archiveDoc(req, res) {
|
||||||
const { doc_id: docId, project_id: projectId } = req.params
|
const { params } = parseReq(req, docParamsSchema, { logOnly: true })
|
||||||
|
const { doc_id: docId, project_id: projectId } = params
|
||||||
logger.debug({ projectId, docId }, 'archiving a doc')
|
logger.debug({ projectId, docId }, 'archiving a doc')
|
||||||
await DocArchive.archiveDoc(projectId, docId)
|
await DocArchive.archiveDoc(projectId, docId)
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unArchiveAllDocs(req, res) {
|
async function unArchiveAllDocs(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'unarchiving all docs')
|
logger.debug({ projectId }, 'unarchiving all docs')
|
||||||
try {
|
try {
|
||||||
await DocArchive.unArchiveAllDocs(projectId)
|
await DocArchive.unArchiveAllDocs(projectId)
|
||||||
@@ -256,7 +369,8 @@ async function unArchiveAllDocs(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function destroyProject(req, res) {
|
async function destroyProject(req, res) {
|
||||||
const { project_id: projectId } = req.params
|
const { params } = parseReq(req, projectParamsSchema, { logOnly: true })
|
||||||
|
const { project_id: projectId } = params
|
||||||
logger.debug({ projectId }, 'destroying all docs')
|
logger.debug({ projectId }, 'destroying all docs')
|
||||||
await DocArchive.destroyProject(projectId)
|
await DocArchive.destroyProject(projectId)
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { z } from '@overleaf/validation-tools'
|
||||||
|
|
||||||
|
// Zod schemas for the ranges data (RangesTracker format) accepted by
|
||||||
|
// updateDoc.
|
||||||
|
//
|
||||||
|
// Ranges reach docstore via web from several writers and are stored (and
|
||||||
|
// later round-tripped, e.g. by project clone) without normalization, so the
|
||||||
|
// schemas model wire reality rather than the ideal shape:
|
||||||
|
// - document-updater flushes RangesTracker data: change/comment ids are
|
||||||
|
// RangesTracker ids (seed + increment) or thread ids, i.e. strings that are
|
||||||
|
// not always ObjectIds (and old fixtures/documents carry arbitrary string
|
||||||
|
// thread ids), see libraries/ranges-tracker/index.cjs
|
||||||
|
// - history restores send id-less changes and comment ops with a `resolved`
|
||||||
|
// flag (stripped again by RangeManager), and detached comments without an
|
||||||
|
// id, see overleaf-editor-core/lib/doc_updater_compatible_ranges.js
|
||||||
|
|
||||||
|
const insertOp = z.strictObject({
|
||||||
|
i: z.string(),
|
||||||
|
p: z.number().int().min(0),
|
||||||
|
u: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteOp = z.strictObject({
|
||||||
|
d: z.string(),
|
||||||
|
p: z.number().int().min(0),
|
||||||
|
u: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const commentOp = z.strictObject({
|
||||||
|
c: z.string().optional(),
|
||||||
|
p: z.number().int().min(0).optional(),
|
||||||
|
// comment thread ids are usually ObjectIds, but legacy documents carry
|
||||||
|
// arbitrary strings
|
||||||
|
t: z.string().optional(),
|
||||||
|
u: z.boolean().optional(),
|
||||||
|
// sent by history restores; removed again by RangeManager
|
||||||
|
resolved: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rangeMetadata = z.strictObject({
|
||||||
|
user_id: z.string().optional(),
|
||||||
|
ts: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const comment = z.strictObject({
|
||||||
|
id: z.string().optional(),
|
||||||
|
op: commentOp,
|
||||||
|
metadata: rangeMetadata.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackedChange = z.strictObject({
|
||||||
|
id: z.string().optional(),
|
||||||
|
op: insertOp.or(deleteOp).optional(),
|
||||||
|
metadata: rangeMetadata.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ranges = z.strictObject({
|
||||||
|
comments: z.array(comment).optional(),
|
||||||
|
changes: z.array(trackedChange).optional(),
|
||||||
|
})
|
||||||
@@ -32,7 +32,7 @@ function deleteTestSuite(deleteDoc) {
|
|||||||
this.doc_id = new ObjectId()
|
this.doc_id = new ObjectId()
|
||||||
this.lines = ['original', 'lines']
|
this.lines = ['original', 'lines']
|
||||||
this.version = 42
|
this.version = 42
|
||||||
this.ranges = []
|
this.ranges = {}
|
||||||
await DocstoreApp.ensureRunning()
|
await DocstoreApp.ensureRunning()
|
||||||
await DocstoreClient.createDoc(
|
await DocstoreClient.createDoc(
|
||||||
this.project_id,
|
this.project_id,
|
||||||
@@ -350,7 +350,7 @@ describe("Destroying a project's documents", function () {
|
|||||||
this.doc_id = new ObjectId()
|
this.doc_id = new ObjectId()
|
||||||
this.lines = ['original', 'lines']
|
this.lines = ['original', 'lines']
|
||||||
this.version = 42
|
this.version = 42
|
||||||
this.ranges = []
|
this.ranges = {}
|
||||||
await DocstoreApp.ensureRunning()
|
await DocstoreApp.ensureRunning()
|
||||||
await DocstoreClient.createDoc(
|
await DocstoreClient.createDoc(
|
||||||
this.project_id,
|
this.project_id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import mongodb from 'mongodb-legacy'
|
import mongodb from 'mongodb-legacy'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
|
import { expectValidationError } from '@overleaf/validation-tools/testUtils.js'
|
||||||
import DocstoreApp from './helpers/DocstoreApp.js'
|
import DocstoreApp from './helpers/DocstoreApp.js'
|
||||||
import DocstoreClient from './helpers/DocstoreClient.js'
|
import DocstoreClient from './helpers/DocstoreClient.js'
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ describe('Getting a doc', function () {
|
|||||||
{
|
{
|
||||||
id: new ObjectId().toString(),
|
id: new ObjectId().toString(),
|
||||||
op: { i: 'foo', p: 3 },
|
op: { i: 'foo', p: 3 },
|
||||||
meta: {
|
metadata: {
|
||||||
user_id: new ObjectId().toString(),
|
user_id: new ObjectId().toString(),
|
||||||
ts: new Date().toJSON(),
|
ts: new Date().toJSON(),
|
||||||
},
|
},
|
||||||
@@ -67,6 +68,47 @@ describe('Getting a doc', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('when the doc id is not a valid ObjectId', function () {
|
||||||
|
it('should return a 404', async function () {
|
||||||
|
let err
|
||||||
|
try {
|
||||||
|
await DocstoreClient.getDoc(this.project_id, 'not-an-object-id')
|
||||||
|
expect.fail('should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
err = error
|
||||||
|
}
|
||||||
|
expectValidationError(err, 404, 'doc_id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the project id is not a valid ObjectId', function () {
|
||||||
|
it('should return a 404', async function () {
|
||||||
|
let err
|
||||||
|
try {
|
||||||
|
await DocstoreClient.getDoc('not-an-object-id', this.doc_id)
|
||||||
|
expect.fail('should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
err = error
|
||||||
|
}
|
||||||
|
expectValidationError(err, 404, 'project_id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the include_deleted query value is invalid', function () {
|
||||||
|
it('should return a 400', async function () {
|
||||||
|
let err
|
||||||
|
try {
|
||||||
|
await DocstoreClient.getDoc(this.project_id, this.doc_id, {
|
||||||
|
include_deleted: 'banana',
|
||||||
|
})
|
||||||
|
expect.fail('should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
err = error
|
||||||
|
}
|
||||||
|
expectValidationError(err, 400, 'include_deleted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('when the doc is a deleted doc', function () {
|
describe('when the doc is a deleted doc', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
this.deleted_doc_id = new ObjectId()
|
this.deleted_doc_id = new ObjectId()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import mongodb from 'mongodb-legacy'
|
import mongodb from 'mongodb-legacy'
|
||||||
|
import { expectValidationError } from '@overleaf/validation-tools/testUtils.js'
|
||||||
import DocstoreApp from './helpers/DocstoreApp.js'
|
import DocstoreApp from './helpers/DocstoreApp.js'
|
||||||
import DocstoreClient from './helpers/DocstoreClient.js'
|
import DocstoreClient from './helpers/DocstoreClient.js'
|
||||||
|
|
||||||
@@ -15,9 +16,9 @@ describe('Applying updates to a doc', function () {
|
|||||||
{
|
{
|
||||||
id: new ObjectId().toString(),
|
id: new ObjectId().toString(),
|
||||||
op: { i: '$foo', p: 3 },
|
op: { i: '$foo', p: 3 },
|
||||||
meta: {
|
metadata: {
|
||||||
user_id: new ObjectId().toString(),
|
user_id: new ObjectId().toString(),
|
||||||
ts: new Date().toString(),
|
ts: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -27,9 +28,9 @@ describe('Applying updates to a doc', function () {
|
|||||||
{
|
{
|
||||||
id: new ObjectId().toString(),
|
id: new ObjectId().toString(),
|
||||||
op: { i: '$bar', p: 6 },
|
op: { i: '$bar', p: 6 },
|
||||||
meta: {
|
metadata: {
|
||||||
user_id: new ObjectId().toString(),
|
user_id: new ObjectId().toString(),
|
||||||
ts: new Date().toString(),
|
ts: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -259,6 +260,43 @@ describe('Applying updates to a doc', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('when an unknown ranges field is provided', function () {
|
||||||
|
let err
|
||||||
|
beforeEach(async function () {
|
||||||
|
try {
|
||||||
|
this.body = await DocstoreClient.updateDoc(
|
||||||
|
this.project_id,
|
||||||
|
this.doc_id,
|
||||||
|
this.newLines,
|
||||||
|
this.version + 1,
|
||||||
|
{ ...this.originalRanges, unknownField: 'x' }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
err = error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400', function () {
|
||||||
|
err.info.status.should.equal(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
// handleValidationError() (@overleaf/validation-tools) composes the body
|
||||||
|
// as { error: fromError(zodError).toString(), statusCode }; fromError()
|
||||||
|
// renders the literal unrecognized-key name for a strictObject
|
||||||
|
// rejection. Asserting on the body (not just the status code) confirms
|
||||||
|
// the *intended* field is what rejected the request, not some unrelated
|
||||||
|
// bug that happens to also return the same status.
|
||||||
|
it('should return a body naming the unknown field', function () {
|
||||||
|
expectValidationError(err, 400, 'unknownField')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not update the doc in the API', async function () {
|
||||||
|
const doc = await DocstoreClient.getDoc(this.project_id, this.doc_id)
|
||||||
|
doc.lines.should.deep.equal(this.originalLines)
|
||||||
|
doc.version.should.equal(this.version)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('when no version is provided', function () {
|
describe('when no version is provided', function () {
|
||||||
let statusCode
|
let statusCode
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
@@ -313,9 +351,13 @@ describe('Applying updates to a doc', function () {
|
|||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
const line = new Array(1025).join('x') // 1kb
|
const line = new Array(1025).join('x') // 1kb
|
||||||
this.largeLines = Array.apply(null, Array(1024)).map(() => line) // 1kb
|
this.largeLines = Array.apply(null, Array(1024)).map(() => line) // 1kb
|
||||||
this.originalRanges.padding = Array.apply(null, Array(2049)).map(
|
// bloat the ranges payload beyond max_doc_length (2mb + 1kb)
|
||||||
() => line
|
this.originalRanges.comments = [
|
||||||
) // 2mb + 1kb
|
{
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
op: { c: line.repeat(2049), p: 0, t: new ObjectId().toString() },
|
||||||
|
},
|
||||||
|
]
|
||||||
this.body = await DocstoreClient.updateDoc(
|
this.body = await DocstoreClient.updateDoc(
|
||||||
this.project_id,
|
this.project_id,
|
||||||
this.doc_id,
|
this.doc_id,
|
||||||
@@ -372,7 +414,13 @@ describe('Applying updates to a doc', function () {
|
|||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
const line = new Array(1024).join('x') // 1 KB
|
const line = new Array(1024).join('x') // 1 KB
|
||||||
this.largeLines = new Array(8192).fill(line) // 8 MB
|
this.largeLines = new Array(8192).fill(line) // 8 MB
|
||||||
this.originalRanges.padding = new Array(6144).fill(line) // 6 MB
|
// bloat the ranges payload by 6 MB
|
||||||
|
this.originalRanges.comments = [
|
||||||
|
{
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
op: { c: line.repeat(6144), p: 0, t: new ObjectId().toString() },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.body = await DocstoreClient.updateDoc(
|
this.body = await DocstoreClient.updateDoc(
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe('HttpController', () => {
|
|||||||
ctx.res.status = sinon.stub().returns(ctx.res)
|
ctx.res.status = sinon.stub().returns(ctx.res)
|
||||||
ctx.req = { query: {} }
|
ctx.req = { query: {} }
|
||||||
ctx.next = sinon.stub()
|
ctx.next = sinon.stub()
|
||||||
ctx.projectId = 'mock-project-id'
|
ctx.projectId = new ObjectId().toString()
|
||||||
ctx.docId = 'mock-doc-id'
|
ctx.docId = new ObjectId().toString()
|
||||||
ctx.doc = {
|
ctx.doc = {
|
||||||
_id: ctx.docId,
|
_id: ctx.docId,
|
||||||
lines: ['mock', 'lines', ' here', '', '', ' spaces '],
|
lines: ['mock', 'lines', ' here', '', '', ' spaces '],
|
||||||
@@ -350,7 +350,18 @@ describe('HttpController', () => {
|
|||||||
ctx.req.body = {
|
ctx.req.body = {
|
||||||
lines: (ctx.lines = ['hello', 'world']),
|
lines: (ctx.lines = ['hello', 'world']),
|
||||||
version: (ctx.version = 42),
|
version: (ctx.version = 42),
|
||||||
ranges: (ctx.ranges = { changes: 'mock' }),
|
ranges: (ctx.ranges = {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
id: new ObjectId().toString(),
|
||||||
|
op: { i: 'foo', p: 3 },
|
||||||
|
metadata: {
|
||||||
|
user_id: new ObjectId().toString(),
|
||||||
|
ts: new Date().toJSON(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
ctx.rev = 5
|
ctx.rev = 5
|
||||||
ctx.DocManager.updateDoc = sinon
|
ctx.DocManager.updateDoc = sinon
|
||||||
@@ -412,14 +423,15 @@ describe('HttpController', () => {
|
|||||||
ctx.DocManager.updateDoc.called.should.equal(false)
|
ctx.DocManager.updateDoc.called.should.equal(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return a 400 (bad request) response', ctx => {
|
it('should pass a validation error to next', ctx => {
|
||||||
ctx.res.sendStatus.calledWith(400).should.equal(true)
|
expect(ctx.next).to.have.been.calledOnce
|
||||||
|
expect(ctx.next.firstCall.args[0].name).to.equal('InvalidRequestError')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the doc version are not provided', () => {
|
describe('when the doc version are not provided', () => {
|
||||||
beforeEach(async ctx => {
|
beforeEach(async ctx => {
|
||||||
ctx.req.body = { version: 42, lines: ['hello world'] }
|
ctx.req.body = { lines: ['hello world'], ranges: {} }
|
||||||
ctx.DocManager.updateDoc = sinon
|
ctx.DocManager.updateDoc = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves({ modified: false, rev: 0 })
|
.resolves({ modified: false, rev: 0 })
|
||||||
@@ -430,8 +442,9 @@ describe('HttpController', () => {
|
|||||||
ctx.DocManager.updateDoc.called.should.equal(false)
|
ctx.DocManager.updateDoc.called.should.equal(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return a 400 (bad request) response', ctx => {
|
it('should pass a validation error to next', ctx => {
|
||||||
ctx.res.sendStatus.calledWith(400).should.equal(true)
|
expect(ctx.next).to.have.been.calledOnce
|
||||||
|
expect(ctx.next.firstCall.args[0].name).to.equal('InvalidRequestError')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -448,8 +461,9 @@ describe('HttpController', () => {
|
|||||||
ctx.DocManager.updateDoc.called.should.equal(false)
|
ctx.DocManager.updateDoc.called.should.equal(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return a 400 (bad request) response', ctx => {
|
it('should pass a validation error to next', ctx => {
|
||||||
ctx.res.sendStatus.calledWith(400).should.equal(true)
|
expect(ctx.next).to.have.been.calledOnce
|
||||||
|
expect(ctx.next.firstCall.args[0].name).to.equal('InvalidRequestError')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,7 +472,7 @@ describe('HttpController', () => {
|
|||||||
ctx.req.body = {
|
ctx.req.body = {
|
||||||
lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))),
|
lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))),
|
||||||
version: (ctx.version = 42),
|
version: (ctx.version = 42),
|
||||||
ranges: (ctx.ranges = { changes: 'mock' }),
|
ranges: (ctx.ranges = {}),
|
||||||
}
|
}
|
||||||
ctx.DocManager.updateDoc = sinon
|
ctx.DocManager.updateDoc = sinon
|
||||||
.stub()
|
.stub()
|
||||||
|
|||||||
Reference in New Issue
Block a user