mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[monorepo] prepare for zod everywhere epic (#36027)
GitOrigin-RevId: f082ae7fc7857fa72b2c1823645050bd4f6df305
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { Request } from 'express'
|
||||
import { parseReq } from '../../../parseReq'
|
||||
import { getRawReqInput, isLockdownInstalled } from '../../../lockdown'
|
||||
import { zz } from '../../../zodHelpers'
|
||||
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
// Replicates what the patched express does to a request in warn/throw mode:
|
||||
// parsed input lives in symbol-keyed fields, the public properties throw.
|
||||
function lockedRequest({
|
||||
params = {},
|
||||
query = {},
|
||||
body = undefined,
|
||||
...rest
|
||||
}: {
|
||||
params?: object
|
||||
query?: object
|
||||
body?: unknown
|
||||
[key: string]: unknown
|
||||
}): Request {
|
||||
const req: Record<PropertyKey, unknown> = { ...rest }
|
||||
req[INSTALLED] = true
|
||||
req[RAW_PARAMS] = params
|
||||
req[RAW_QUERY] = query
|
||||
req[RAW_BODY] = body
|
||||
for (const field of ['params', 'query', 'body']) {
|
||||
Object.defineProperty(req, field, {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error(`raw request input is forbidden (req.${field})`)
|
||||
},
|
||||
set(value) {
|
||||
this[
|
||||
field === 'params'
|
||||
? RAW_PARAMS
|
||||
: field === 'query'
|
||||
? RAW_QUERY
|
||||
: RAW_BODY
|
||||
] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
return req as unknown as Request
|
||||
}
|
||||
|
||||
describe('parseReq with the request-input lockdown installed', () => {
|
||||
it('validates params/query/body from the raw fields without touching the public accessors', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
query: { verbose: 'true' },
|
||||
body: { name: 'Valid Name' },
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
query: z.strictObject({ verbose: z.stringbool() }),
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
|
||||
expect(parseReq(req, schema)).toEqual({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
query: { verbose: true },
|
||||
body: { name: 'Valid Name' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lets schemas reference unlocked request fields such as headers', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
headers: { range: 'bytes=0-99' },
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
headers: z.object({ range: z.string() }),
|
||||
})
|
||||
|
||||
expect(parseReq(req, schema)).toEqual({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
headers: { range: 'bytes=0-99' },
|
||||
})
|
||||
})
|
||||
|
||||
it('throws InvalidParamsError for invalid raw params', () => {
|
||||
const req = lockedRequest({ params: { id: 'not-an-object-id' } })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, z.object({ params: z.strictObject({ id: zz.objectId() }) }))
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidParamsError' }))
|
||||
})
|
||||
|
||||
it('rejects unknown keys in strict objects from the raw fields', () => {
|
||||
const req = lockedRequest({ body: { name: 'ok', extra: 'nope' } })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, z.object({ body: z.strictObject({ name: z.string() }) }))
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidRequestError' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLockdownInstalled', () => {
|
||||
it('reports the lockdown state of a request', () => {
|
||||
expect(isLockdownInstalled(lockedRequest({}))).toBe(true)
|
||||
expect(isLockdownInstalled({} as Request)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRawReqInput', () => {
|
||||
it('reads the raw fields on a locked request', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
|
||||
expect(getRawReqInput(req)).toEqual({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the public properties on an unlocked request', () => {
|
||||
const req = {
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
} as unknown as Request
|
||||
|
||||
expect(getRawReqInput(req)).toEqual({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { Request } from 'express'
|
||||
import {
|
||||
parseReq,
|
||||
setLogger,
|
||||
setReqValidationModeForTests,
|
||||
resetReqValidationLoggingForTests,
|
||||
} from '../../../parseReq'
|
||||
import { zz } from '../../../zodHelpers'
|
||||
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
// Replicates what the patched express does to a request in warn/throw mode
|
||||
// (see lockdown.test.ts): parsed input lives in symbol-keyed fields, the
|
||||
// public properties throw.
|
||||
function lockedRequest({
|
||||
params = {},
|
||||
query = {},
|
||||
body = undefined,
|
||||
...rest
|
||||
}: {
|
||||
params?: object
|
||||
query?: object
|
||||
body?: unknown
|
||||
[key: string]: unknown
|
||||
}): Request {
|
||||
const req: Record<PropertyKey, unknown> = { ...rest }
|
||||
req[INSTALLED] = true
|
||||
req[RAW_PARAMS] = params
|
||||
req[RAW_QUERY] = query
|
||||
req[RAW_BODY] = body
|
||||
for (const field of ['params', 'query', 'body']) {
|
||||
Object.defineProperty(req, field, {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error(`raw request input is forbidden (req.${field})`)
|
||||
},
|
||||
set(value) {
|
||||
this[
|
||||
field === 'params'
|
||||
? RAW_PARAMS
|
||||
: field === 'query'
|
||||
? RAW_QUERY
|
||||
: RAW_BODY
|
||||
] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
return req as unknown as Request
|
||||
}
|
||||
|
||||
type WarnCall = [Record<string, any>, string]
|
||||
|
||||
describe('parseReq log-only rollout', () => {
|
||||
// This block intentionally runs before anything else in the file calls
|
||||
// setLogger(), so the module-level logger is still unset here.
|
||||
describe('without an injected logger', () => {
|
||||
beforeEach(() => {
|
||||
resetReqValidationLoggingForTests()
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
it('is a no-op: a logOnly failure still returns the raw input without throwing', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
let result: unknown
|
||||
expect(() => {
|
||||
result = parseReq(req, schema, { logOnly: true })
|
||||
}).not.toThrow()
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an injected logger', () => {
|
||||
let warnMock: ReturnType<typeof vi.fn<(ctx: any, msg: string) => void>>
|
||||
|
||||
beforeEach(() => {
|
||||
warnMock = vi.fn<(ctx: any, msg: string) => void>()
|
||||
setLogger({ warn: warnMock })
|
||||
resetReqValidationLoggingForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('enforce mode', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('enforce')
|
||||
})
|
||||
|
||||
it('throws even when opts is fully populated -- opts are inert in enforce mode', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, schema, { logOnly: true, fallbackSchema: z.any() })
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidRequestError' }))
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still classifies params failures as InvalidParamsError with opts set', () => {
|
||||
const req = { params: { id: 'nope' } } as Request<{ id: string }>
|
||||
const schema = z.object({
|
||||
params: z.object({ id: z.string().regex(/^[0-9]+$/) }),
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, schema, { logOnly: true, fallbackSchema: z.any() })
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidParamsError' }))
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: the task description's spec text says "Default (unset OR any
|
||||
// invalid value) is 'log'" for REQ_VALIDATION_MODE (repeated for
|
||||
// setReqValidationModeForTests: "coerced the same way the real getter
|
||||
// would"), which is also the only reading consistent with the rollout's
|
||||
// safety goal (fail open to logging, not enforcing, when a service
|
||||
// hasn't configured the var). We test that documented behavior here.
|
||||
describe('an invalid REQ_VALIDATION_MODE value', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('bogus')
|
||||
})
|
||||
|
||||
it('behaves as log mode (the documented default for unset/invalid values)', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, { logOnly: true })
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('log mode', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
it('still throws when no opts are passed, and logs nothing', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
expect(() => parseReq(req, schema)).toThrowError(
|
||||
expect.objectContaining({ name: 'InvalidRequestError' })
|
||||
)
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('logOnly', () => {
|
||||
it('returns the raw, un-coerced request input on failure', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, { logOnly: true })
|
||||
expect(result).toBe(req)
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
})
|
||||
|
||||
it('logs exactly once for a repeated identical failure against the same schema', () => {
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs again when the issues differ, against the same schema', () => {
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req1 = { body: { name: 1234 } } as Request
|
||||
const req2 = { body: { name: 'ok', extra: true } } as Request
|
||||
|
||||
parseReq(req1, schema, { logOnly: true })
|
||||
parseReq(req2, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('logs again for a different schema object, even with identical issues (per-schema dedup)', () => {
|
||||
const schemaA = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const schemaB = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
|
||||
parseReq(req, schemaA, { logOnly: true })
|
||||
parseReq(req, schemaB, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the request-input lockdown installed', () => {
|
||||
it('returns the raw, unwrapped params/query/body on a logOnly failure', () => {
|
||||
const req = lockedRequest({ params: { id: 'not-an-object-id' } })
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
}) as unknown as Request
|
||||
|
||||
expect(() => result.params).not.toThrow()
|
||||
expect(result.params).toEqual({ id: 'not-an-object-id' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a fallbackSchema', () => {
|
||||
it('returns the fallback output and logs the primary issues (fallback-passed) when the fallback passes', () => {
|
||||
const req = { body: { count: '5' } } as Request
|
||||
const primary = z.object({ body: z.object({ count: z.number() }) })
|
||||
const fallback = z.object({
|
||||
body: z.object({ count: z.coerce.number().default(0) }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, primary, { fallbackSchema: fallback })
|
||||
|
||||
expect(result).toEqual({ body: { count: 5 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
expect(ctx.kind).toBe('fallback-passed')
|
||||
expect(ctx.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'body.count' }),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('throws classified from the fallback error when both schemas fail and logOnly is not set', () => {
|
||||
const req = {
|
||||
params: { id: 'not-a-number' },
|
||||
body: { name: 'ok' },
|
||||
} as Request<{ id: string }, any, { name: string }>
|
||||
// Fails on body (name is a string, not a number).
|
||||
const primary = z.object({
|
||||
params: z.object({ id: z.string() }),
|
||||
body: z.object({ name: z.number() }),
|
||||
})
|
||||
// Fails on params (id is a string, not a number).
|
||||
const fallback = z.object({
|
||||
params: z.object({ id: z.number() }),
|
||||
body: z.object({ name: z.string() }),
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, primary, { fallbackSchema: fallback })
|
||||
).toThrowError(
|
||||
expect.objectContaining({ name: 'InvalidParamsError' })
|
||||
)
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns raw input and logs once (log-only) when both schemas fail and logOnly is set', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const primary = z.object({ body: z.object({ name: z.string() }) })
|
||||
const fallback = z.object({
|
||||
body: z.object({ name: z.string().min(10) }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, primary, {
|
||||
fallbackSchema: fallback,
|
||||
logOnly: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
expect(ctx.kind).toBe('log-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue sanitization', () => {
|
||||
it('never leaks a sentinel value present in the request', () => {
|
||||
const req = { body: { name: 'SECRET_VALUE_123' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.number() }) })
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
|
||||
'SECRET_VALUE_123'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes an unrecognized key NAME but never its value', () => {
|
||||
const req = {
|
||||
body: { name: 'ok', extraSecretField: 'TOP_SECRET_VALUE' },
|
||||
} as Request
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain('extraSecretField')
|
||||
expect(logged).not.toContain('TOP_SECRET_VALUE')
|
||||
})
|
||||
|
||||
it('truncates a >64-char path segment to exactly 64 chars', () => {
|
||||
const longKey = 'x'.repeat(65)
|
||||
const req = { body: { [longKey]: 'irrelevant' } } as Request
|
||||
const schema = z.object({
|
||||
body: z.object({ [longKey]: z.number() }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain(longKey.slice(0, 64))
|
||||
expect(logged).not.toContain(longKey)
|
||||
})
|
||||
|
||||
it('truncates a >200-char message to exactly 200 chars', () => {
|
||||
const longMessage = 'y'.repeat(250)
|
||||
const req = { body: { name: 'short' } } as Request
|
||||
const schema = z.object({
|
||||
body: z.object({
|
||||
name: z.string().refine(() => false, { message: longMessage }),
|
||||
}),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain(longMessage.slice(0, 200))
|
||||
expect(logged).not.toContain(longMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid_union sanitization', () => {
|
||||
it('recursively sanitizes nested union member issues without leaking sentinel values', () => {
|
||||
const req = {
|
||||
body: { value: { secret: 'UNION_SECRET_VALUE' } },
|
||||
} as Request
|
||||
const schema = z.object({
|
||||
body: z.object({ value: z.union([z.string(), z.number()]) }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
const unionIssue = ctx.issues.find(
|
||||
(issue: any) => issue.code === 'invalid_union'
|
||||
)
|
||||
expect(unionIssue).toBeDefined()
|
||||
expect(Array.isArray(unionIssue.errors)).toBe(true)
|
||||
expect(unionIssue.errors.length).toBeGreaterThan(0)
|
||||
|
||||
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
|
||||
'UNION_SECRET_VALUE'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('success path', () => {
|
||||
it('enforce mode: returns the parsed data and logs nothing, even with opts set', () => {
|
||||
setReqValidationModeForTests('enforce')
|
||||
const req = { body: { name: 'ok' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
fallbackSchema: z.any(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 'ok' } })
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('log mode: returns the parsed data and logs nothing, even with opts set', () => {
|
||||
setReqValidationModeForTests('log')
|
||||
const req = { body: { name: 'ok' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
fallbackSchema: z.any(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 'ok' } })
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
expectValidationError,
|
||||
expectValidationErrorRaw,
|
||||
} from '../../../testUtils'
|
||||
|
||||
function errorBody(status: number, fieldSubstring: string) {
|
||||
return JSON.stringify({
|
||||
statusCode: status,
|
||||
error: `Validation error: Unrecognized key: "${fieldSubstring}" at "body"`,
|
||||
})
|
||||
}
|
||||
|
||||
// Mimics a RequestFailedError from @overleaf/fetch-utils: `.response.status`
|
||||
// and `.body` (raw text).
|
||||
function asRequestFailedError(status: number, fieldSubstring: string) {
|
||||
return {
|
||||
response: { status },
|
||||
body: errorBody(status, fieldSubstring),
|
||||
}
|
||||
}
|
||||
|
||||
// Mimics a raw node:http response object: `.statusCode` and `.body` (raw
|
||||
// text) directly on the object.
|
||||
function asRawResponse(status: number, fieldSubstring: string) {
|
||||
return {
|
||||
statusCode: status,
|
||||
body: errorBody(status, fieldSubstring),
|
||||
}
|
||||
}
|
||||
|
||||
// Mimics an HTTP client wrapper that never rejects and pre-parses the body
|
||||
// from JSON before returning it, e.g. templates' fetchWithResponse().
|
||||
function asResolvedResultWithParsedBody(
|
||||
status: number,
|
||||
fieldSubstring: string
|
||||
) {
|
||||
return {
|
||||
response: { status },
|
||||
body: JSON.parse(errorBody(status, fieldSubstring)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('expectValidationError', () => {
|
||||
it('passes when the status and field substring match', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 400, 'projectId')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the status code does not match', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 404, 'projectId')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the field substring is not present in the error body', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 400, 'someOtherField')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the object is not a rejected request', () => {
|
||||
expect(() =>
|
||||
expectValidationError({ notAResponse: true }, 400, 'projectId')
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('passes when the body is already parsed into an object', () => {
|
||||
const result = asResolvedResultWithParsedBody(404, 'template_id')
|
||||
expect(() =>
|
||||
expectValidationError(result, 404, 'template_id')
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the pre-parsed body does not include the field substring', () => {
|
||||
const result = asResolvedResultWithParsedBody(404, 'template_id')
|
||||
expect(() => expectValidationError(result, 404, 'someOtherField')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expectValidationErrorRaw', () => {
|
||||
it('passes when the status and field substring match', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 404, 'filename')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the status code does not match', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 400, 'filename')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the field substring is not present in the error body', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 404, 'projectId')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -334,4 +334,569 @@ describe('zodHelpers', () => {
|
||||
expect(parsed.data).toBe('foo/output.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeSegment', () => {
|
||||
it('fails to parse with empty input', () => {
|
||||
const parsed = zz.routeSegment().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toHaveLength(1)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is empty',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a path separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo/bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a backslash', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo\\bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a query separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo?bar=1')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a fragment separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo#bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a relative path component', () => {
|
||||
const parsed = zz.routeSegment().safeParse('..')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is a relative path component',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a single dot', () => {
|
||||
const parsed = zz.routeSegment().safeParse('.')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is a relative path component',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a plain segment', () => {
|
||||
const parsed = zz.routeSegment().safeParse('conversion')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('conversion')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a mongo ObjectId-shaped segment', () => {
|
||||
const parsed = zz.routeSegment().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safePath', () => {
|
||||
it('fails to parse with empty input', () => {
|
||||
const parsed = zz.safePath().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path is empty',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully with an absolute (root-relative) path', () => {
|
||||
// unlike filepath(), a leading "/" is allowed -- project doc/file
|
||||
// paths are root-relative in production (see web's
|
||||
// ProjectEntityHandler.getAllEntitiesFromProject).
|
||||
const parsed = zz.safePath().safeParse('/output.pdf')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('/output.pdf')
|
||||
})
|
||||
|
||||
it('fails to parse a path ending in a slash (a folder, not a file)', () => {
|
||||
const parsed = zz.safePath().safeParse('foo/')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path is a folder, not a file',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse when provided with path traversal', () => {
|
||||
const parsed = zz.safePath().safeParse('../output.pdf')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path traversal detected',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "."', () => {
|
||||
// matches SafePath.mjs's BADFILE_RX (^\.$), not just "..": a lone "."
|
||||
// component is also rejected there, cross-checked against
|
||||
// SafePath.test.mjs's isCleanFilename/isCleanPath cases.
|
||||
const parsed = zz.safePath().safeParse('.')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path segment with leading whitespace', () => {
|
||||
const parsed = zz.safePath().safeParse(' foobar.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path segment with trailing whitespace', () => {
|
||||
const parsed = zz.safePath().safeParse('foobar.tex ')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a null byte', () => {
|
||||
const parsed = zz
|
||||
.safePath()
|
||||
.safeParse('foo' + String.fromCharCode(0) + '.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a C1 control character', () => {
|
||||
// \x80-\x9F, per SafePath.mjs's BADCHAR_RX
|
||||
const parsed = zz
|
||||
.safePath()
|
||||
.safeParse('foo' + String.fromCharCode(0x90) + '.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a lone surrogate', () => {
|
||||
// \uD800-\uDFFF, per SafePath.mjs's BADCHAR_RX
|
||||
const parsed = zz.safePath().safeParse('foo\uD800.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing an asterisk', () => {
|
||||
const parsed = zz.safePath().safeParse('foo*.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a backslash', () => {
|
||||
const parsed = zz.safePath().safeParse('foo\\bar.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path with a __proto__ segment', () => {
|
||||
const parsed = zz.safePath().safeParse('__proto__/output.pdf')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "constructor"', () => {
|
||||
const parsed = zz.safePath().safeParse('constructor')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a nested segment matching a built-in Object.prototype method name', () => {
|
||||
// full BLOCKEDFILE_RX parity, not just the prototype-pollution subset
|
||||
// (__proto__/constructor/prototype) -- and checked per segment, not
|
||||
// only the top-level path as SafePath.mjs's isCleanPath does.
|
||||
const parsed = zz.safePath().safeParse('foo/toString')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "hasOwnProperty"', () => {
|
||||
const parsed = zz.safePath().safeParse('hasOwnProperty')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid nested path', () => {
|
||||
const parsed = zz.safePath().safeParse('foo/output.pdf')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('foo/output.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectHistoryId', () => {
|
||||
it('fails to parse an invalid id', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('not-an-id')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid project history id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal payload', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('a/../../../../../etc')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid project history id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Mongo ObjectId', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Postgres id', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('12345')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('12345')
|
||||
})
|
||||
|
||||
it('fails to parse a Postgres id with a leading zero', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('0123')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkId', () => {
|
||||
it('fails to parse an invalid id', () => {
|
||||
const parsed = zz.chunkId().safeParse('not-an-id')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid chunk id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal payload', () => {
|
||||
const parsed = zz.chunkId().safeParse('a/../../../../../etc')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid chunk id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Mongo ObjectId', () => {
|
||||
const parsed = zz.chunkId().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Postgres id', () => {
|
||||
const parsed = zz.chunkId().safeParse('12345')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('12345')
|
||||
})
|
||||
|
||||
it('fails to parse a Postgres id with a leading zero', () => {
|
||||
const parsed = zz.chunkId().safeParse('0123')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitTestName', () => {
|
||||
it('fails to parse a non-kebab-case name', () => {
|
||||
const parsed = zz.splitTestName().safeParse('Not_Valid')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a name shorter than 3 characters', () => {
|
||||
const parsed = zz.splitTestName().safeParse('ab')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be at least 3 characters long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal-shaped payload', () => {
|
||||
const parsed = zz.splitTestName().safeParse('../../etc/passwd')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid kebab-case name', () => {
|
||||
const parsed = zz.splitTestName().safeParse('my-split-test')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('my-split-test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('variantName', () => {
|
||||
it('fails to parse a non-kebab-case name', () => {
|
||||
const parsed = zz.variantName().safeParse('Not_Valid')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a name shorter than 3 characters', () => {
|
||||
const parsed = zz.variantName().safeParse('ab')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be at least 3 characters long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal-shaped payload', () => {
|
||||
const parsed = zz.variantName().safeParse('../../etc/passwd')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid kebab-case name', () => {
|
||||
const parsed = zz.variantName().safeParse('variant-1')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('variant-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventName', () => {
|
||||
it('fails to parse a name containing a disallowed character', () => {
|
||||
const parsed = zz.eventName().safeParse('bad event!')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid event name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse an empty string', () => {
|
||||
// both the explicit .min(1) and the regex reject an empty string, so
|
||||
// two issues are reported, each with the same message
|
||||
const parsed = zz.eventName().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues.length).toBeGreaterThan(0)
|
||||
for (const issue of parsed.error?.issues ?? []) {
|
||||
expect(issue).toMatchObject({ message: 'invalid event name' })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses successfully when provided a name with letters, digits and hyphens/underscores', () => {
|
||||
const parsed = zz.eventName().safeParse('editor-click_something-2')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('editor-click_something-2')
|
||||
})
|
||||
|
||||
it('fails to parse a name containing an uppercase letter or dots, colons, semicolons, commas and slashes', () => {
|
||||
const parsed = zz.eventName().safeParse('I.did:something,here/now;too')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid event name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a name that is exactly 240 characters long', () => {
|
||||
const name = 'a'.repeat(240)
|
||||
const parsed = zz.eventName().safeParse(name)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(name)
|
||||
})
|
||||
|
||||
it('fails to parse a name that is 241 characters long', () => {
|
||||
const name = 'a'.repeat(241)
|
||||
const parsed = zz.eventName().safeParse(name)
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'event name is too long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamedBody', () => {
|
||||
it('parses undefined (no body parser mounted) to undefined', () => {
|
||||
const parsed = zz.streamedBody().safeParse(undefined)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(undefined)
|
||||
})
|
||||
|
||||
it('parses the empty object left by a skipping body parser to undefined', () => {
|
||||
const parsed = zz.streamedBody().safeParse({})
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(undefined)
|
||||
})
|
||||
|
||||
it('fails to parse a parsed JSON body', () => {
|
||||
const parsed = zz.streamedBody().safeParse({ some: 'payload' })
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'unexpected parsed request body on streamed endpoint',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a raw buffer body', () => {
|
||||
const parsed = zz.streamedBody().safeParse(Buffer.alloc(0))
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails to parse a string body', () => {
|
||||
const parsed = zz.streamedBody().safeParse('text body')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadedFile', () => {
|
||||
const validFile = {
|
||||
fieldname: 'qqfile',
|
||||
originalname: 'paper.tex',
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/x-tex',
|
||||
size: 1234,
|
||||
destination: '/tmp/uploads',
|
||||
filename: 'a1b2c3d4e5f6',
|
||||
path: '/tmp/uploads/a1b2c3d4e5f6',
|
||||
}
|
||||
|
||||
it('parses a multer disk-storage file object', () => {
|
||||
const parsed = zz.uploadedFile().safeParse(validFile)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toEqual(validFile)
|
||||
})
|
||||
|
||||
it('parses a file object without the deprecated encoding field', () => {
|
||||
const { encoding, ...rest } = validFile
|
||||
const parsed = zz.uploadedFile().safeParse(rest)
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('fails to parse an originalname with path traversal', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, originalname: '../../etc/passwd' })
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path traversal detected',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse an absolute originalname', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, originalname: '/etc/passwd' })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown keys such as a memory-storage buffer', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, buffer: Buffer.alloc(1) })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails to parse a negative size', () => {
|
||||
const parsed = zz.uploadedFile().safeParse({ ...validFile, size: -1 })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user