chore: import upstream snapshot with attribution
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { setDownloadHeaders } from '../../../src/server/utils/downloadHelpers';
|
||||
import type { Response } from 'express';
|
||||
|
||||
describe('downloadHelpers', () => {
|
||||
describe('setDownloadHeaders', () => {
|
||||
let mockRes: Partial<Response>;
|
||||
let setHeaderMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setHeaderMock = vi.fn().mockReturnThis();
|
||||
mockRes = {
|
||||
setHeader: setHeaderMock,
|
||||
} as Partial<Response>;
|
||||
});
|
||||
|
||||
it('should set correct headers for CSV download', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test-file.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/csv');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="test-file.csv"',
|
||||
);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Cache-Control',
|
||||
'no-cache, no-store, must-revalidate',
|
||||
);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Pragma', 'no-cache');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Expires', '0');
|
||||
});
|
||||
|
||||
it('should set correct headers for JSON download', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'data.json', 'application/json');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'application/json');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="data.json"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'file with spaces & special.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="file with spaces & special.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle very long filenames', () => {
|
||||
const longFileName = 'a'.repeat(255) + '.csv';
|
||||
setDownloadHeaders(mockRes as Response, longFileName, 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${longFileName}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Unicode characters in filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, '测试文件.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="测试文件.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different content types', () => {
|
||||
const testCases = [
|
||||
{ fileName: 'file.pdf', contentType: 'application/pdf' },
|
||||
{ fileName: 'file.xml', contentType: 'application/xml' },
|
||||
{ fileName: 'file.txt', contentType: 'text/plain' },
|
||||
{ fileName: 'file.yaml', contentType: 'application/x-yaml' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ fileName, contentType }) => {
|
||||
setHeaderMock.mockClear();
|
||||
setDownloadHeaders(mockRes as Response, fileName, contentType);
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', contentType);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${fileName}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set all cache control headers', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
|
||||
|
||||
// Verify all cache-preventing headers are set
|
||||
const calls = setHeaderMock.mock.calls;
|
||||
const headerMap = new Map(calls.map((call) => [call[0] as string, call[1]]));
|
||||
|
||||
expect(headerMap.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(headerMap.get('Pragma')).toBe('no-cache');
|
||||
expect(headerMap.get('Expires')).toBe('0');
|
||||
});
|
||||
|
||||
it('should be called exactly 5 times for all headers', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should handle empty filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, '', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename=""');
|
||||
});
|
||||
|
||||
it('should handle filename with quotes', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'file"with"quotes.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="file"with"quotes.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle filename with path separators', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'path/to/file.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="path/to/file.csv"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as legacyEvalTableUtils from '../../../src/server/utils/evalTableUtils';
|
||||
import * as evalTableUtils from '../../../src/util/eval/evalTableUtils';
|
||||
|
||||
describe('legacy evalTableUtils import', () => {
|
||||
it('re-exports the node-layer implementation for enterprise compatibility', () => {
|
||||
expect(legacyEvalTableUtils.evalTableToCsv).toBe(evalTableUtils.evalTableToCsv);
|
||||
expect(legacyEvalTableUtils.streamEvalCsv).toBe(evalTableUtils.streamEvalCsv);
|
||||
expect(legacyEvalTableUtils.mergeComparisonTables).toBe(evalTableUtils.mergeComparisonTables);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user