diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..5438c114ea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[Makefile] +indent_style = tab + +[*.go] +indent_style = tab + +[*.{pug,coffee}] +indent_style = tab + +[*.{pug,patch}] +trim_trailing_whitespace = false diff --git a/libraries/validation-tools/.nvmrc b/libraries/validation-tools/.nvmrc new file mode 100644 index 0000000000..91d5f6ff8e --- /dev/null +++ b/libraries/validation-tools/.nvmrc @@ -0,0 +1 @@ +22.18.0 diff --git a/libraries/validation-tools/Errors.js b/libraries/validation-tools/Errors.js new file mode 100644 index 0000000000..e6a12d4e54 --- /dev/null +++ b/libraries/validation-tools/Errors.js @@ -0,0 +1,5 @@ +const OError = require('@overleaf/o-error') + +class ParamsError extends OError {} + +module.exports = { ParamsError } diff --git a/libraries/validation-tools/buildscript.txt b/libraries/validation-tools/buildscript.txt new file mode 100644 index 0000000000..9c43574c8b --- /dev/null +++ b/libraries/validation-tools/buildscript.txt @@ -0,0 +1,10 @@ +validation-tools +--dependencies=None +--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker +--env-add= +--env-pass-through= +--esmock-loader=False +--is-library=True +--node-version=22.18.0 +--public-repo=False +--script-version=4.7.0 diff --git a/libraries/validation-tools/index.js b/libraries/validation-tools/index.js new file mode 100644 index 0000000000..0536e62b27 --- /dev/null +++ b/libraries/validation-tools/index.js @@ -0,0 +1,11 @@ +const { ParamsError } = require('./Errors') +const { z } = require('zod') +const { zz } = require('./zodHelpers') +const { validateReq } = require('./validateReq') + +module.exports = { + z, + zz, + validateReq, + ParamsError, +} diff --git a/libraries/validation-tools/package.json b/libraries/validation-tools/package.json new file mode 100644 index 0000000000..cdacea6e2d --- /dev/null +++ b/libraries/validation-tools/package.json @@ -0,0 +1,31 @@ +{ + "name": "@overleaf/validation-tools", + "homepage": "www.overleaf.com", + "description": "Validation tools that can be used in a service.", + "repository": { + "type": "git", + "url": "https://github.com/overleaf/overleaf" + }, + "main": "index.js", + "license": "AGPL-3.0-only", + "version": "1.0.0", + "scripts": { + "test": "npm run lint && npm run format && npm run types:check && npm run test:unit", + "format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'", + "format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'", + "lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .", + "lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .", + "test:ci": "npm run test:unit", + "test:unit": "vitest test/unit/src/*.test.ts --isolate=false", + "types:check": "tsc --noEmit" + }, + "dependencies": { + "@overleaf/o-error": "*", + "mongodb": "^6.12.0", + "zod": "^4.1.8" + }, + "devDependencies": { + "typescript": "^5.0.4", + "vitest": "^3.2.4" + } +} diff --git a/libraries/validation-tools/test/unit/src/validateReq.test.ts b/libraries/validation-tools/test/unit/src/validateReq.test.ts new file mode 100644 index 0000000000..4a716d7a69 --- /dev/null +++ b/libraries/validation-tools/test/unit/src/validateReq.test.ts @@ -0,0 +1,85 @@ +import { validateReq } from '../../../validateReq' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import type { Request } from 'express' +import { zz } from '../../../zodHelpers' + +describe('validateReq', () => { + describe('with a request that is valid for the schema', () => { + it('should return the parsed request', () => { + const req = { + params: { + id: '507f1f77bcf86cd799439011', + }, + body: { + name: 'Valid Name', + }, + } as Request<{ id: string }, any, { name: string }> + + const schema = z.object({ + params: z.object({ + id: zz.objectId(), + }), + body: z.object({ + name: z.string(), + }), + }) + + const result = validateReq(req, schema) + + expect(result).toEqual({ + params: { + id: '507f1f77bcf86cd799439011', + }, + body: { + name: 'Valid Name', + }, + }) + }) + }) + describe('with a request that is not valid for the schema', () => { + it('should throw NotFoundError if params are invalid', () => { + const req = { + params: { + id: 'invalid-object-id', + }, + } as Request<{ id: string }> + + expect(() => + validateReq( + req, + z.object({ + params: z.object({ + id: zz.objectId(), + }), + }) + ) + ).toThrowError(expect.objectContaining({ name: 'ParamsError' })) + }) + + it('should throw an error containing issues if the schema is invalid', () => { + const req = { + body: { + name: 1234, + }, + } as Request + + expect(() => + validateReq( + req, + z.object({ + body: z.object({ + name: z.string(), + }), + }) + ) + ).toThrowError( + expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ path: ['body', 'name'] }), + ]), + }) + ) + }) + }) +}) diff --git a/libraries/validation-tools/test/unit/src/zodHelpers.test.ts b/libraries/validation-tools/test/unit/src/zodHelpers.test.ts new file mode 100644 index 0000000000..08fb6e758c --- /dev/null +++ b/libraries/validation-tools/test/unit/src/zodHelpers.test.ts @@ -0,0 +1,44 @@ +import { zz } from '../../../zodHelpers' +import { describe, expect, it } from 'vitest' +import mongodb from 'mongodb' + +const { ObjectId } = mongodb + +describe('zodHelpers', () => { + describe('objectId', () => { + it('fails to parse when provided with an invalid ObjectId', () => { + const parsed = zz.objectId().safeParse('aa') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + message: 'invalid Mongo ObjectId', + }), + ]) + }) + + it('parses successfully when provided with a valid ObjectId', () => { + const parsed = zz.objectId().safeParse('507f1f77bcf86cd799439011') + expect(parsed.success).toBe(true) + expect(parsed.data).toBe('507f1f77bcf86cd799439011') + }) + }) + describe('coercedObjectId', () => { + it('fails to parse when provided with an invalid ObjectId', () => { + const parsed = zz.coercedObjectId().safeParse('aa') + expect(parsed.success).toBe(false) + expect(parsed.error?.issues).toHaveLength(1) + expect(parsed.error?.issues).toMatchObject([ + expect.objectContaining({ + message: 'invalid Mongo ObjectId', + }), + ]) + }) + it('parses to an ObjectId when provided with a valid ObjectId string', () => { + const parsed = zz.coercedObjectId().safeParse('507f1f77bcf86cd799439011') + expect(parsed.success).toBe(true) + expect(parsed.data).toBeInstanceOf(ObjectId) + expect(parsed.data?.toString()).toBe('507f1f77bcf86cd799439011') + }) + }) +}) diff --git a/libraries/validation-tools/tsconfig.json b/libraries/validation-tools/tsconfig.json new file mode 100644 index 0000000000..4ca8a45254 --- /dev/null +++ b/libraries/validation-tools/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.backend.json", + "compilerOptions": { + "allowImportingTsExtensions": true + }, + "include": [ + "**/*.js", + "**/*.ts", + "**/*.cjs" + ] +} diff --git a/libraries/validation-tools/validateReq.js b/libraries/validation-tools/validateReq.js new file mode 100644 index 0000000000..36c6a5a36f --- /dev/null +++ b/libraries/validation-tools/validateReq.js @@ -0,0 +1,34 @@ +// @ts-check +const { ParamsError } = require('./Errors') + +/** + * @typedef {import('zod').ZodType} ZodType + * @typedef {import('express').Request} Request + */ + +/** + * @template T + * @typedef {import('zod').output} output + */ + +/** + * Validate a request against a zod schema + * + * @template {ZodType} T + * @param {Request} req - The Express request object + * @param {T} schema - The Zod schema to validate against + * @returns {output} The validated request object + */ +function validateReq(req, schema) { + const parsed = schema.safeParse(req) + if (parsed.success) { + return parsed.data + } else if (parsed.error.issues.some(issue => issue.path[0] === 'params')) { + // Parts of the URL path failed to validate; throw a specific error + throw new ParamsError('Invalid params').withCause(parsed.error) + } else { + throw parsed.error + } +} + +module.exports = { validateReq } diff --git a/libraries/validation-tools/zodHelpers.js b/libraries/validation-tools/zodHelpers.js new file mode 100644 index 0000000000..5e9d8cb3b3 --- /dev/null +++ b/libraries/validation-tools/zodHelpers.js @@ -0,0 +1,17 @@ +const { z } = require('zod') +const mongodb = require('mongodb') + +const { ObjectId } = mongodb + +const zz = { + objectId: () => + z.string().refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }), + coercedObjectId: () => + z + .string() + .refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }) + .transform(val => new ObjectId(val)), + hex: () => z.string().regex(/^[0-9a-f]*$/), +} + +module.exports = { zz }