diff --git a/libraries/validation-tools/parseReq.js b/libraries/validation-tools/parseReq.js index 9a313980d8..21f4059440 100644 --- a/libraries/validation-tools/parseReq.js +++ b/libraries/validation-tools/parseReq.js @@ -32,6 +32,10 @@ const { * issues are logged). If it also fails, behavior falls back to logOnly * above, classifying/throwing from the fallback's ZodError when logOnly * is not set. + * @property {string[]} [logFields] - Dotted field paths (e.g. 'body.zipUrl') + * whose raw input values are resolved and included in the schema-failure + * log entry. Values are only resolved on failure; strings are truncated + * to 200 chars. */ /** @@ -201,8 +205,10 @@ function sanitizeIssues(issues, depth = 0) { * @param {ZodType} schema - the primary schema; the dedup key. * @param {'log-only' | 'fallback-passed'} kind * @param {readonly ZodIssue[]} issues + * @param {Request} input - the (possibly lockdown-unwrapped) request input + * @param {string[]} [logFields] - dotted paths to resolve from input */ -function logSchemaFailure(req, schema, kind, issues) { +function logSchemaFailure(req, schema, kind, issues, input, logFields) { if (!logger) return const sanitizedIssues = sanitizeIssues(issues) @@ -226,8 +232,32 @@ function logSchemaFailure(req, schema, kind, issues) { .slice(2, 7) .join('\n') + /** @type {Record | undefined} */ + let resolvedFields + if (logFields) { + resolvedFields = {} + for (const field of logFields) { + let val = /** @type {any} */ (input) + for (const seg of field.split('.')) { + if (val == null || typeof val !== 'object') { + val = '' + break + } + val = val[seg] + } + resolvedFields[field] = typeof val === 'string' ? val.slice(0, 200) : val + } + } + logger.warn( - { location, kind, issues: sanitizedIssues, caller, req }, + { + location, + kind, + issues: sanitizedIssues, + caller, + req, + ...(resolvedFields && { failingValues: resolvedFields }), + }, 'req-validation: request failed schema in log-only rollout' ) } @@ -274,12 +304,26 @@ function parseReq(req, schema, opts) { if (opts.fallbackSchema) { const fallbackParsed = opts.fallbackSchema.safeParse(input) if (fallbackParsed.success) { - logSchemaFailure(req, schema, 'fallback-passed', parsed.error.issues) + logSchemaFailure( + req, + schema, + 'fallback-passed', + parsed.error.issues, + input, + opts.logFields + ) // The fallback's output, not T's -- see the return-type doc comment. return /** @type {output} */ (fallbackParsed.data) } if (opts.logOnly) { - logSchemaFailure(req, schema, 'log-only', parsed.error.issues) + logSchemaFailure( + req, + schema, + 'log-only', + parsed.error.issues, + input, + opts.logFields + ) // The raw, un-coerced input, not T's -- see the return-type doc // comment. return /** @type {output} */ (input) @@ -289,7 +333,14 @@ function parseReq(req, schema, opts) { // preserve production's pre-refinement error shape. throwClassified(fallbackParsed.error) } else if (opts.logOnly) { - logSchemaFailure(req, schema, 'log-only', parsed.error.issues) + logSchemaFailure( + req, + schema, + 'log-only', + parsed.error.issues, + input, + opts.logFields + ) // The raw, un-coerced input, not T's -- see the return-type doc // comment. return /** @type {output} */ (input) diff --git a/libraries/validation-tools/test/unit/src/parseReqLogOnlyRollout.test.ts b/libraries/validation-tools/test/unit/src/parseReqLogOnlyRollout.test.ts index fb9a241d41..08e870f73b 100644 --- a/libraries/validation-tools/test/unit/src/parseReqLogOnlyRollout.test.ts +++ b/libraries/validation-tools/test/unit/src/parseReqLogOnlyRollout.test.ts @@ -437,6 +437,105 @@ describe('parseReq log-only rollout', () => { }) }) + describe('logFields', () => { + it('resolves a dotted path and includes it in failingValues (logOnly)', () => { + const req = { body: { zipUrl: '/bad/path' } } as Request + const schema = z.object({ + body: z.object({ zipUrl: z.string().url() }), + }) + + parseReq(req, schema, { + logOnly: true, + logFields: ['body.zipUrl'], + }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.failingValues).toEqual({ 'body.zipUrl': '/bad/path' }) + }) + + it('resolves a dotted path and includes it in failingValues (fallback-passed)', () => { + const req = { body: { zipUrl: '/bad/path' } } as Request + const primary = z.object({ + body: z.object({ zipUrl: z.string().url() }), + }) + const fallback = z.object({ + body: z.object({ zipUrl: z.string() }), + }) + + parseReq(req, primary, { + fallbackSchema: fallback, + logFields: ['body.zipUrl'], + }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.kind).toBe('fallback-passed') + expect(ctx.failingValues).toEqual({ 'body.zipUrl': '/bad/path' }) + }) + + it('uses for a path that does not exist in the input', () => { + const req = { body: {} } as Request + const schema = z.object({ + body: z.object({ zipUrl: z.string() }), + }) + + parseReq(req, schema, { + logOnly: true, + logFields: ['body.zipUrl'], + }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.failingValues).toEqual({ 'body.zipUrl': undefined }) + }) + + it('uses when an intermediate segment is missing', () => { + const req = { body: undefined } as unknown as Request + const schema = z.object({ + body: z.object({ zipUrl: z.string() }), + }) + + parseReq(req, schema, { + logOnly: true, + logFields: ['body.zipUrl'], + }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.failingValues).toEqual({ 'body.zipUrl': '' }) + }) + + it('truncates string values longer than 200 chars', () => { + const longValue = 'z'.repeat(250) + const req = { body: { zipUrl: longValue } } as Request + const schema = z.object({ + body: z.object({ zipUrl: z.string().url() }), + }) + + parseReq(req, schema, { + logOnly: true, + logFields: ['body.zipUrl'], + }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.failingValues['body.zipUrl']).toHaveLength(200) + expect(ctx.failingValues['body.zipUrl']).toBe(longValue.slice(0, 200)) + }) + + it('omits failingValues when logFields is not set', () => { + const req = { body: { name: 1234 } } as Request + const schema = z.object({ body: z.object({ name: z.string() }) }) + + parseReq(req, schema, { logOnly: true }) + + expect(warnMock).toHaveBeenCalledTimes(1) + const [ctx] = warnMock.mock.calls[0] as WarnCall + expect(ctx.failingValues).toBeUndefined() + }) + }) + describe('success path', () => { it('enforce mode: returns the parsed data and logs nothing, even with opts set', () => { setReqValidationModeForTests('enforce')