Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

290 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { z } from 'zod';
import logger from '../../../src/logger';
import { replyValidationError, sendError } from '../../../src/server/utils/errors';
import type { Response } from 'express';
vi.mock('../../../src/logger', () => ({
default: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
function createResponseMock(): {
res: Response;
status: Mock;
json: Mock;
} {
const json = vi.fn().mockReturnThis();
const status = vi.fn(() => ({ json }));
return {
res: { status, json } as unknown as Response,
status: status as unknown as Mock,
json,
};
}
describe('server/utils/errors', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('sendError', () => {
it('returns the public message and never leaks the internal error', () => {
const { res, status, json } = createResponseMock();
sendError(res, 500, 'Failed to do thing', new Error('secret db password leaked'));
expect(status).toHaveBeenCalledWith(500);
const body = json.mock.calls[0]?.[0];
expect(body).toEqual({ error: 'Failed to do thing' });
expect(JSON.stringify(body)).not.toContain('secret db password');
});
it('skips logging when no internal error is provided', () => {
const { res } = createResponseMock();
sendError(res, 404, 'Not found');
expect(logger.error).not.toHaveBeenCalled();
});
it('logs Error name, message, and stack as a structured object', () => {
const { res } = createResponseMock();
const err = new Error('boom');
err.stack = 'Error: boom\n at someFn';
sendError(res, 500, 'Failed', err);
expect(logger.error).toHaveBeenCalledTimes(1);
const [message, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(message).toBe('Failed');
expect(context).toEqual({
error: {
name: 'Error',
message: 'boom',
stack: 'Error: boom\n at someFn',
},
});
});
it('preserves Error.cause when present, serialized to retain its stack', () => {
const { res } = createResponseMock();
const cause = new Error('underlying');
cause.stack = 'Error: underlying\n at someInnerFn';
const err = new Error('wrapper', { cause });
sendError(res, 500, 'Failed', err);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as { error: { cause: { name: string; message: string; stack: string } } };
expect(ctx.error.cause.name).toBe('Error');
expect(ctx.error.cause.message).toBe('underlying');
// Stack must survive — serializing the raw Error reference would have
// dropped it via JSON.stringify, which is the regression this helper exists to prevent.
expect(ctx.error.cause.stack).toContain('someInnerFn');
});
it('passes non-Error values through verbatim', () => {
const { res } = createResponseMock();
sendError(res, 500, 'Failed', 'plain string error');
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(context).toEqual({ error: 'plain string error' });
});
it('logs `null` as a real (no-detail) failure rather than skipping', () => {
// Pin current behavior: the guard is `internalError !== undefined`, so
// `null` reaches the logger. This is intentional — a future refactor to
// `if (internalError)` would silently swallow falsy-but-real signals
// (e.g. `0`, `false`, `''`).
const { res } = createResponseMock();
sendError(res, 500, 'Failed', null);
expect(logger.error).toHaveBeenCalledTimes(1);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(context).toEqual({ error: null });
});
it('preserves enumerable diagnostic fields on Error subclasses', () => {
// Node SystemError carries `code`/`errno`/`syscall`/`path` as enumerable
// own props; AWS SDK errors carry `$metadata`/`$fault`; fetch errors
// carry `code`/`errno`. Logging the explicit four fields without
// spreading would silently drop these — exactly the production-triage
// regression flagged by the Codex P2 reviewer on this PR.
const { res } = createResponseMock();
const sysErr = Object.assign(new Error('ENOENT: no such file or directory, open ...'), {
code: 'ENOENT',
errno: -2,
syscall: 'open',
path: '/missing/file',
});
sendError(res, 500, 'Failed', sysErr);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as {
error: {
name: string;
message: string;
stack: string;
code: string;
errno: number;
syscall: string;
path: string;
};
};
expect(ctx.error.name).toBe('Error');
expect(ctx.error.code).toBe('ENOENT');
expect(ctx.error.errno).toBe(-2);
expect(ctx.error.syscall).toBe('open');
expect(ctx.error.path).toBe('/missing/file');
// Standard fields still win even though the subclass also has its own.
expect(typeof ctx.error.stack).toBe('string');
});
it('preserves Error subclass name (TypeError, custom subclasses)', () => {
const { res } = createResponseMock();
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
sendError(res, 500, 'Failed', new TypeError('not a string'));
sendError(res, 500, 'Failed', new CustomError('bespoke'));
const calls = (logger.error as unknown as Mock).mock.calls;
expect((calls[0][1] as { error: { name: string } }).error.name).toBe('TypeError');
expect((calls[1][1] as { error: { name: string } }).error.name).toBe('CustomError');
});
it('recursively unwraps Error.cause chains so nested stacks survive serialization', () => {
const { res } = createResponseMock();
const root = new Error('root cause');
root.stack = 'Error: root cause\n at deep';
const middle = new Error('middle', { cause: root });
middle.stack = 'Error: middle\n at middle';
const top = new Error('top', { cause: middle });
top.stack = 'Error: top\n at top';
sendError(res, 500, 'Failed', top);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as {
error: {
stack: string;
cause: { stack: string; cause: { stack: string } };
};
};
expect(ctx.error.stack).toContain('top');
expect(ctx.error.cause.stack).toContain('middle');
expect(ctx.error.cause.cause.stack).toContain('deep');
});
it('does not crash on a circular Error.cause', () => {
const { res, status } = createResponseMock();
const cyclic = new Error('cyclic') as Error & { cause?: unknown };
cyclic.cause = cyclic;
expect(() => sendError(res, 500, 'Failed', cyclic)).not.toThrow();
// The response must still ship — cycle detection in serializeError is
// the entire reason this canonical 500 funnel doesn't unhandled-throw.
expect(status).toHaveBeenCalledWith(500);
});
it('caps long Error.cause chains before serializing unbounded logger context', () => {
const { res } = createResponseMock();
const chain = Array.from({ length: 6 }, (_, index) => {
const err = new Error(`level-${index}`) as Error & { cause?: unknown };
err.stack = `Error: level-${index}\n at level${index}`;
return err;
});
for (let i = 0; i < chain.length - 1; i++) {
chain[i].cause = chain[i + 1];
}
sendError(res, 500, 'Failed', chain[0]);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const cappedCause = (
context as {
error: { cause: { cause: { cause: { cause: unknown } } } };
}
).error.cause.cause.cause.cause;
expect(cappedCause).toEqual({ name: 'Error', message: 'level-4' });
expect(JSON.stringify(context)).not.toContain('level-5');
});
it('falls back to a hand-built envelope if ErrorResponseSchema.parse throws', async () => {
// Force the schema parse to throw, simulating a future tightening that
// rejects what `sendError` produces. Without the safeRespond fallback,
// the throw would escape the handler's try/catch and land in Express's
// default error path (empty 500 with no JSON body).
const common = await import('../../../src/types/api/common');
const parseSpy = vi.spyOn(common.ErrorResponseSchema, 'parse').mockImplementationOnce(() => {
throw new Error('forced schema rejection');
});
try {
const { res, status, json } = createResponseMock();
expect(() => sendError(res, 500, 'Public message')).not.toThrow();
expect(status).toHaveBeenCalledWith(500);
expect(json.mock.calls[0]?.[0]).toEqual({ error: 'Public message' });
expect(parseSpy).toHaveBeenCalledTimes(1);
} finally {
parseSpy.mockRestore();
}
});
});
describe('replyValidationError', () => {
let zodError: z.ZodError;
beforeEach(() => {
const schema = z.object({
name: z.string(),
age: z.number().int().nonnegative(),
});
const result = schema.safeParse({ name: 42, age: -1 });
if (result.success) {
throw new Error('expected parse to fail');
}
zodError = result.error;
});
it('returns a 400 with prettified message in `error`', () => {
const { res, status, json } = createResponseMock();
replyValidationError(res, zodError);
expect(status).toHaveBeenCalledWith(400);
const body = json.mock.calls[0]?.[0] as { error: string };
expect(typeof body.error).toBe('string');
expect(body.error.length).toBeGreaterThan(0);
});
it('exposes structured Zod issues in `details` for programmatic clients', () => {
const { res, json } = createResponseMock();
replyValidationError(res, zodError);
const body = json.mock.calls[0]?.[0] as {
details: { issues: z.core.$ZodIssue[] };
};
expect(Array.isArray(body.details.issues)).toBe(true);
expect(body.details.issues.length).toBe(zodError.issues.length);
const paths = body.details.issues.map((issue) => issue.path.join('.'));
expect(paths).toContain('name');
expect(paths).toContain('age');
});
});
});