Files
wehub-resource-sync 0d3cb498a3
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1223 lines
41 KiB
TypeScript

import { spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
import { asMockChildProcess, createMockChildProcess } from '../../util/mockChildProcess';
// Mock dependencies
vi.mock('child_process');
vi.mock('../../../src/util/modelAuditInstall', () => ({
checkModelAuditInstalled: vi.fn(),
}));
import { getDb } from '../../../src/database/index';
import { modelAuditsTable } from '../../../src/database/tables';
import { runDbMigrations } from '../../../src/migrate';
import ModelAudit from '../../../src/models/modelAudit';
import {
CheckInstalledResponseSchema,
CheckPathResponseSchema,
DeleteScanResponseSchema,
GetScanResponseSchema,
ListScannersResponseSchema,
ListScansResponseSchema,
ModelAuditSchemas,
} from '../../../src/types/api/modelAudit';
// Import after mocking
import { checkModelAuditInstalled } from '../../../src/util/modelAuditInstall';
const mockedCheckModelAuditInstalled = vi.mocked(checkModelAuditInstalled);
const mockedSpawn = vi.mocked(spawn);
describe('Model Audit Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
// Reset mock implementations to ensure test isolation when tests run in random order.
// vi.clearAllMocks() only clears call history, not mockResolvedValue/mockReturnValue.
mockedCheckModelAuditInstalled.mockReset();
mockedSpawn.mockReset();
});
describe('GET /api/model-audit/scanners', () => {
it('should return the scanner catalog', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const scannerOutput = JSON.stringify({
scanners: [
{
id: 'pickle',
class: 'PickleScanner',
description: 'Scans pickle files',
extensions: ['.pkl'],
dependencies: [],
},
],
});
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: scannerOutput,
}),
),
);
const response = await api.get('/api/model-audit/scanners');
expect(response.status).toBe(200);
expect(response.body.scanners).toHaveLength(1);
expect(response.body.scanners[0].id).toBe('pickle');
expect(() => ListScannersResponseSchema.parse(response.body)).not.toThrow();
expect(mockedSpawn).toHaveBeenCalledWith(
'modelaudit',
['scan', '--format', 'json', '--list-scanners'],
expect.objectContaining({
env: expect.objectContaining({
PROMPTFOO_DELEGATED: 'true',
}),
}),
);
});
it('should return 400 when scanners are requested without modelaudit installed', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: false, version: null });
const response = await api.get('/api/model-audit/scanners');
expect(response.status).toBe(400);
expect(response.body.error).toContain('ModelAudit is not installed');
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('should return 500 when scanner listing exits non-zero', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 2,
stderrData: 'scanner lookup failed',
}),
),
);
const response = await api.get('/api/model-audit/scanners');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to list ModelAudit scanners');
});
it('should return 500 when scanner listing terminates via signal', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
customEventHandlers: {
close: (callback) => setImmediate(() => callback(null, 'SIGTERM')),
},
}),
),
);
const response = await api.get('/api/model-audit/scanners');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to list ModelAudit scanners');
});
it('should return 500 when scanner output is invalid JSON', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: 'not json',
}),
),
);
const response = await api.get('/api/model-audit/scanners');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to list ModelAudit scanners');
});
});
describe('POST /api/model-audit/scan', () => {
it('should handle request without options object', async () => {
// This tests the fix for: options = {} default when options is undefined
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.20' });
// Create a temporary test file
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-scan.pkl');
fs.writeFileSync(testFilePath, 'test data');
const mockScanOutput = JSON.stringify({
total_checks: 5,
passed_checks: 5,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
// Use the test utility for cleaner mock creation
const mockChildProcess = createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
});
mockedSpawn.mockReturnValue(asMockChildProcess(mockChildProcess));
// Request WITHOUT options - this would have caused "Cannot read properties of undefined" before the fix
const response = await api.post('/api/model-audit/scan').send({ paths: [testFilePath] });
// Clean up
fs.unlinkSync(testFilePath);
// Should succeed without TypeError
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('total_checks', 5);
expect(response.body).toHaveProperty('has_errors', false);
});
it('should handle request with empty options object', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.20' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-scan-2.pkl');
fs.writeFileSync(testFilePath, 'test data');
const mockScanOutput = JSON.stringify({
total_checks: 5,
passed_checks: 5,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
// Use the test utility for cleaner mock creation
const mockChildProcess = createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
});
mockedSpawn.mockReturnValue(asMockChildProcess(mockChildProcess));
// Request with empty options object
const response = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: {} });
fs.unlinkSync(testFilePath);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('total_checks', 5);
});
it('should accept timeout 0 from the UI and apply the route default', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.20' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-zero-timeout.pkl');
fs.writeFileSync(testFilePath, 'test data');
const mockScanOutput = JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
}),
),
);
try {
const response = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { timeout: 0 } });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('total_checks', 1);
expect(mockedSpawn).toHaveBeenCalledWith(
'modelaudit',
expect.arrayContaining(['--timeout', '3600']),
expect.any(Object),
);
} finally {
fs.unlinkSync(testFilePath);
}
});
it('should pass and persist scanner selection options', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-scanner-selection.pkl');
fs.writeFileSync(testFilePath, 'test data');
const createSpy = vi.spyOn(ModelAudit, 'create').mockResolvedValue({
id: 'scan-scanner-selection',
} as Awaited<ReturnType<typeof ModelAudit.create>>);
const mockScanOutput = JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
}),
),
);
try {
const response = await api.post('/api/model-audit/scan').send({
paths: [testFilePath],
options: {
scanners: ['pickle,tf_savedmodel'],
excludeScanner: ['weight_distribution'],
},
});
expect(response.status).toBe(200);
expect(response.body.auditId).toBe('scan-scanner-selection');
expect(response.body.persisted).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
'modelaudit',
expect.arrayContaining([
'scan',
testFilePath,
'--scanners',
'pickle,tf_savedmodel',
'--exclude-scanner',
'weight_distribution',
]),
expect.objectContaining({
env: expect.objectContaining({
PROMPTFOO_DELEGATED: 'true',
}),
}),
);
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
options: expect.objectContaining({
scanners: ['pickle,tf_savedmodel'],
excludeScanner: ['weight_distribution'],
}),
}),
}),
);
} finally {
createSpy.mockRestore();
fs.unlinkSync(testFilePath);
}
});
it('should normalize maxFileSize to maxSize and reject removed maxTotalSize', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-size-alias.pkl');
fs.writeFileSync(testFilePath, 'test data');
const mockScanOutput = JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
}),
),
);
try {
const aliasResponse = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { maxFileSize: '500MB', persist: false } });
expect(aliasResponse.status).toBe(200);
expect(mockedSpawn).toHaveBeenCalledWith(
'modelaudit',
expect.arrayContaining(['--max-size', '500MB']),
expect.any(Object),
);
const removedAliasResponse = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { maxTotalSize: '2GB' } });
expect(removedAliasResponse.status).toBe(400);
expect(removedAliasResponse.body.error).toContain('maxTotalSize is no longer supported');
} finally {
fs.unlinkSync(testFilePath);
}
});
it('should reject incomplete scanner JSON instead of persisting it as a clean scan', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-incomplete-json.pkl');
fs.writeFileSync(testFilePath, 'test data');
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: JSON.stringify({}),
}),
),
);
try {
const response = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { persist: false } });
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to parse scan results');
} finally {
fs.unlinkSync(testFilePath);
}
});
it('should fail closed when the scanner process terminates via signal', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-signal.pkl');
fs.writeFileSync(testFilePath, 'test data');
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
stdoutData: JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
has_errors: false,
issues: [],
checks: [],
}),
customEventHandlers: {
close: (callback) => setImmediate(() => callback(null, 'SIGTERM')),
},
}),
),
);
try {
const response = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { persist: false } });
expect(response.status).toBe(500);
expect(response.body.error).toContain('terminated by signal SIGTERM');
} finally {
fs.unlinkSync(testFilePath);
}
});
it('should preserve multibyte UTF-8 when scanner JSON arrives across chunks', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-utf8.pkl');
fs.writeFileSync(testFilePath, 'test data');
const output = JSON.stringify({
total_checks: 1,
passed_checks: 0,
failed_checks: 1,
files_scanned: 1,
bytes_scanned: 9,
has_errors: true,
issues: [{ severity: 'critical', message: 'cafe\u0301' }],
checks: [{ name: 'pickle', status: 'failed', message: 'cafe\u0301' }],
});
const bytes = Buffer.from(output);
const accentOffset = bytes.indexOf(Buffer.from('\u0301')) + 1;
const splitUtf8Child = {
stdout: {
on: vi.fn().mockImplementation(function (event: string, callback: any) {
if (event === 'data') {
callback(bytes.subarray(0, accentOffset));
callback(bytes.subarray(accentOffset));
}
return splitUtf8Child.stdout;
}),
},
stderr: {
on: vi.fn().mockImplementation(function () {
return splitUtf8Child.stderr;
}),
},
killed: false,
kill: vi.fn(),
on: vi.fn().mockImplementation(function (event: string, callback: any) {
if (event === 'close') {
callback(1, null);
}
return splitUtf8Child;
}),
};
mockedSpawn.mockReturnValue(asMockChildProcess(splitUtf8Child as any));
try {
const response = await api
.post('/api/model-audit/scan')
.send({ paths: [testFilePath], options: { persist: false } });
expect(response.status).toBe(200);
expect(response.body.issues[0].message).toBe('cafe\u0301');
expect(response.body.checks[0].message).toBe('cafe\u0301');
} finally {
fs.unlinkSync(testFilePath);
}
});
it('should persist scanner version and content hash for API-created scans', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-provenance.pkl');
fs.writeFileSync(testFilePath, 'test data');
const createSpy = vi.spyOn(ModelAudit, 'create').mockResolvedValue({
id: 'scan-provenance',
} as Awaited<ReturnType<typeof ModelAudit.create>>);
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
content_hash: 'sha256:abc123',
}),
}),
),
);
try {
const response = await api.post('/api/model-audit/scan').send({ paths: [testFilePath] });
expect(response.status).toBe(200);
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
scannerVersion: '0.2.30',
contentHash: 'sha256:abc123',
}),
);
} finally {
createSpy.mockRestore();
fs.unlinkSync(testFilePath);
}
});
it('should not double-respond when both error and close events fire after a parse-throw', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-double-respond.pkl');
fs.writeFileSync(testFilePath, 'test data');
// Race `error` then `close` so both fire and both call into safeRespond.
// Under the pre-fix code, parse threw *before* `responded = true` was
// set, so the close emitter could re-enter safeRespond and write a
// second body.
const errorThenClose = {
on: vi.fn().mockImplementation(function (
this: object,
event: string,
callback: (...args: unknown[]) => void,
) {
if (event === 'error') {
setImmediate(() => callback(new Error('boom')));
} else if (event === 'close') {
// Fire close immediately after error so both safeRespond paths race.
setImmediate(() => setImmediate(() => callback(1)));
}
return this;
}),
};
const mockProc = createMockChildProcess({ exitCode: 1 });
mockProc.on = errorThenClose.on as never;
mockedSpawn.mockReturnValue(asMockChildProcess(mockProc));
// Force the error-branch parse to throw on the first call. Under the
// post-fix code `responded` flips to `true` before parse runs, so the
// close emitter that fires next observes `responded === true` and
// returns without entering parse a second time.
const errorParseSpy = vi
.spyOn(ModelAuditSchemas.Scan.ErrorResponse, 'parse')
.mockImplementationOnce(() => {
throw new Error('forced error-branch parse failure');
});
try {
const response = await api
.post('/api/model-audit/scan')
.timeout({ deadline: 1000 })
.send({ paths: [testFilePath], options: { persist: false } });
expect(response.status).toBe(500);
expect(typeof response.body.error).toBe('string');
// `safeRespond` calls `Scan.ErrorResponse.parse` exactly once per
// *entered* invocation. If `responded` were not flipped before parse,
// the close emitter would re-enter and trigger a second parse call —
// so this single-call assertion is the proxy for "exactly one body
// was written to the response stream".
expect(errorParseSpy).toHaveBeenCalledTimes(1);
} finally {
errorParseSpy.mockRestore();
fs.unlinkSync(testFilePath);
}
});
it('should fall back to a 500 response when scan success DTO parsing fails', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.30' });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-response-parse.pkl');
fs.writeFileSync(testFilePath, 'test data');
const mockScanOutput = JSON.stringify({
total_checks: 1,
passed_checks: 1,
failed_checks: 0,
files_scanned: 1,
bytes_scanned: 9,
has_errors: false,
issues: [],
checks: [],
});
mockedSpawn.mockReturnValue(
asMockChildProcess(
createMockChildProcess({
exitCode: 0,
stdoutData: mockScanOutput,
}),
),
);
const parseSpy = vi
.spyOn(ModelAuditSchemas.Scan.Response, 'parse')
.mockImplementationOnce(() => {
throw new Error('bad scan response DTO');
});
try {
const response = await api
.post('/api/model-audit/scan')
.timeout({ deadline: 1000 })
.send({ paths: [testFilePath], options: { persist: false } });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Error processing scan results' });
expect(parseSpy).toHaveBeenCalled();
} finally {
parseSpy.mockRestore();
fs.unlinkSync(testFilePath);
}
});
it('should return 400 when no paths provided', async () => {
const response = await api.post('/api/model-audit/scan').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when paths is empty array', async () => {
const response = await api.post('/api/model-audit/scan').send({ paths: [] });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when paths is not an array', async () => {
const response = await api.post('/api/model-audit/scan').send({ paths: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when all paths are empty strings', async () => {
const response = await api.post('/api/model-audit/scan').send({ paths: ['', ' '] });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when modelaudit is not installed', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: false, version: null });
const testFilePath = path.join(os.tmpdir(), 'test-model-audit-not-installed.pkl');
fs.writeFileSync(testFilePath, 'test data');
const response = await api.post('/api/model-audit/scan').send({ paths: [testFilePath] });
fs.unlinkSync(testFilePath);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('ModelAudit is not installed');
});
it('should return 400 when path does not exist', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.20' });
const response = await api
.post('/api/model-audit/scan')
.send({ paths: ['/nonexistent/path/to/model.pkl'] });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('Path does not exist');
});
});
describe('GET /api/model-audit/check-installed', () => {
it('should return installed status when modelaudit is available', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: true, version: '0.2.20' });
const response = await api.get('/api/model-audit/check-installed');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('installed', true);
expect(response.body).toHaveProperty('version', '0.2.20');
expect(response.body).toHaveProperty('cwd');
// Validate against schema
expect(() => CheckInstalledResponseSchema.parse(response.body)).not.toThrow();
});
it('should return not installed status when modelaudit is unavailable', async () => {
mockedCheckModelAuditInstalled.mockResolvedValue({ installed: false, version: null });
const response = await api.get('/api/model-audit/check-installed');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('installed', false);
expect(response.body).toHaveProperty('version', null);
// Validate against schema
expect(() => CheckInstalledResponseSchema.parse(response.body)).not.toThrow();
});
it('should handle checkModelAuditInstalled throwing', async () => {
mockedCheckModelAuditInstalled.mockRejectedValue(new Error('pip not found'));
const response = await api.get('/api/model-audit/check-installed');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('installed', false);
expect(response.body).toHaveProperty('version', null);
expect(() => CheckInstalledResponseSchema.parse(response.body)).not.toThrow();
});
});
});
describe('Model Audit Routes - DB-backed', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await runDbMigrations();
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(async () => {
mockedCheckModelAuditInstalled.mockReset();
mockedSpawn.mockReset();
// Clean up model_audits table
const db = await getDb();
await db.run('DELETE FROM model_audits');
});
afterEach(() => {
vi.resetAllMocks();
});
async function createTestAudit(
overrides: Partial<Parameters<typeof ModelAudit.create>[0]> & {
createdAt?: number;
updatedAt?: number;
id?: string;
} = {},
) {
const { createdAt, updatedAt, id, ...createOverrides } = overrides;
const baseData = {
name: 'Test Scan',
modelPath: '/path/to/model.pkl',
results: {
total_checks: 5,
passed_checks: 5,
failed_checks: 0,
has_errors: false,
issues: [],
checks: [],
},
...createOverrides,
};
if (createdAt !== undefined || updatedAt !== undefined || id !== undefined) {
const audit = new ModelAudit({
...baseData,
id,
createdAt,
updatedAt,
checks: baseData.results.checks || null,
issues: baseData.results.issues || null,
totalChecks: baseData.results.total_checks || null,
passedChecks: baseData.results.passed_checks || null,
failedChecks: baseData.results.failed_checks || null,
});
await audit.save();
return audit;
}
return ModelAudit.create(baseData);
}
describe('POST /api/model-audit/check-path', () => {
it('should return 400 when path is missing', async () => {
const response = await api.post('/api/model-audit/check-path').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when path is empty string', async () => {
const response = await api.post('/api/model-audit/check-path').send({ path: '' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when path is whitespace only', async () => {
const response = await api.post('/api/model-audit/check-path').send({ path: ' ' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return exists: false for non-existent path', async () => {
const response = await api
.post('/api/model-audit/check-path')
.send({ path: '/nonexistent/path/abc123' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ exists: false, type: null });
expect(() => CheckPathResponseSchema.parse(response.body)).not.toThrow();
});
it('should return directory info for existing directory', async () => {
const response = await api.post('/api/model-audit/check-path').send({ path: os.tmpdir() });
expect(response.status).toBe(200);
expect(response.body.exists).toBe(true);
expect(response.body.type).toBe('directory');
expect(response.body.absolutePath).toBeTruthy();
expect(response.body.name).toBeTruthy();
expect(() => CheckPathResponseSchema.parse(response.body)).not.toThrow();
});
it('should return file info for existing file', async () => {
const testFile = path.join(os.tmpdir(), 'test-check-path.txt');
fs.writeFileSync(testFile, 'test');
const response = await api.post('/api/model-audit/check-path').send({ path: testFile });
fs.unlinkSync(testFile);
expect(response.status).toBe(200);
expect(response.body.exists).toBe(true);
expect(response.body.type).toBe('file');
expect(response.body.name).toBe('test-check-path.txt');
expect(() => CheckPathResponseSchema.parse(response.body)).not.toThrow();
});
it('should expand ~ in paths', async () => {
const response = await api.post('/api/model-audit/check-path').send({ path: '~/' });
expect(response.status).toBe(200);
// Home directory should exist
expect(response.body.exists).toBe(true);
expect(response.body.type).toBe('directory');
});
});
describe('GET /api/model-audit/scans', () => {
it('should return empty list when no scans exist', async () => {
const response = await api.get('/api/model-audit/scans');
expect(response.status).toBe(200);
expect(response.body.scans).toEqual([]);
expect(response.body.total).toBe(0);
expect(response.body.limit).toBe(100);
expect(response.body.offset).toBe(0);
expect(() => ListScansResponseSchema.parse(response.body)).not.toThrow();
});
it('should return scans with default pagination', async () => {
await createTestAudit({ name: 'Scan A' });
await createTestAudit({ name: 'Scan B' });
const response = await api.get('/api/model-audit/scans');
expect(response.status).toBe(200);
expect(response.body.scans).toHaveLength(2);
expect(response.body.total).toBe(2);
expect(() => ListScansResponseSchema.parse(response.body)).not.toThrow();
});
it('should respect limit and offset', async () => {
await createTestAudit({ name: 'Scan 1' });
await createTestAudit({ name: 'Scan 2' });
await createTestAudit({ name: 'Scan 3' });
const response = await api.get('/api/model-audit/scans?limit=2&offset=1');
expect(response.status).toBe(200);
expect(response.body.scans).toHaveLength(2);
expect(response.body.total).toBe(3);
expect(response.body.limit).toBe(2);
expect(response.body.offset).toBe(1);
});
it('should filter by search term', async () => {
await createTestAudit({ name: 'Alpha Model', modelPath: '/path/alpha.pkl' });
await createTestAudit({ name: 'Beta Model', modelPath: '/path/beta.pkl' });
const response = await api.get('/api/model-audit/scans?search=Alpha');
expect(response.status).toBe(200);
expect(response.body.scans).toHaveLength(1);
expect(response.body.scans[0].name).toBe('Alpha Model');
expect(response.body.total).toBe(1);
});
it('should sort by name ascending', async () => {
await createTestAudit({ name: 'Zebra' });
await createTestAudit({ name: 'Apple' });
const response = await api.get('/api/model-audit/scans?sort=name&order=asc');
expect(response.status).toBe(200);
expect(response.body.scans[0].name).toBe('Apple');
expect(response.body.scans[1].name).toBe('Zebra');
});
it('should use scan id as a stable tie-breaker for non-unique sort fields', async () => {
const db = await getDb();
const scanResults = {
total_checks: 5,
passed_checks: 5,
failed_checks: 0,
has_errors: false,
issues: [],
checks: [],
};
await db
.insert(modelAuditsTable)
.values([
{
id: 'scan-b',
createdAt: 1,
updatedAt: 1,
name: 'Same name',
modelPath: '/path/b.pkl',
results: scanResults,
hasErrors: false,
totalChecks: 5,
passedChecks: 5,
failedChecks: 0,
},
{
id: 'scan-a',
createdAt: 2,
updatedAt: 2,
name: 'Same name',
modelPath: '/path/a.pkl',
results: scanResults,
hasErrors: false,
totalChecks: 5,
passedChecks: 5,
failedChecks: 0,
},
])
.run();
const ascResponse = await api.get('/api/model-audit/scans?sort=name&order=asc');
const descResponse = await api.get('/api/model-audit/scans?sort=name&order=desc');
expect(ascResponse.status).toBe(200);
expect(ascResponse.body.scans.map((scan: { id: string }) => scan.id)).toEqual([
'scan-a',
'scan-b',
]);
expect(descResponse.status).toBe(200);
expect(descResponse.body.scans.map((scan: { id: string }) => scan.id)).toEqual([
'scan-b',
'scan-a',
]);
});
it('should sort by id ascending', async () => {
await createTestAudit({ name: 'Second scan' });
await createTestAudit({ name: 'First scan' });
const response = await api.get('/api/model-audit/scans?sort=id&order=asc');
expect(response.status).toBe(200);
expect(response.body.scans).toHaveLength(2);
expect(response.body.scans.map((scan: { id: string }) => scan.id)).toEqual(
[...response.body.scans.map((scan: { id: string }) => scan.id)].sort(),
);
});
it('should sort by status and check counts', async () => {
await createTestAudit({
name: 'Clean scan',
results: {
total_checks: 10,
passed_checks: 10,
failed_checks: 0,
has_errors: false,
issues: [],
checks: [],
},
});
await createTestAudit({
name: 'Issues scan',
results: {
total_checks: 3,
passed_checks: 1,
failed_checks: 2,
has_errors: true,
issues: [{ severity: 'error', message: 'Issue found' }],
checks: [],
},
});
const statusResponse = await api.get('/api/model-audit/scans?sort=hasErrors&order=desc');
const checksResponse = await api.get('/api/model-audit/scans?sort=totalChecks&order=asc');
expect(statusResponse.status).toBe(200);
expect(statusResponse.body.scans[0].name).toBe('Issues scan');
expect(checksResponse.status).toBe(200);
expect(checksResponse.body.scans[0].name).toBe('Issues scan');
});
it('should return 400 for invalid sort field', async () => {
const response = await api.get('/api/model-audit/scans?sort=hackerField');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for invalid sort order', async () => {
const response = await api.get('/api/model-audit/scans?order=sideways');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for limit exceeding max', async () => {
const response = await api.get('/api/model-audit/scans?limit=999');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for negative offset', async () => {
const response = await api.get('/api/model-audit/scans?offset=-1');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for limit of 0', async () => {
const response = await api.get('/api/model-audit/scans?limit=0');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for non-numeric limit', async () => {
const response = await api.get('/api/model-audit/scans?limit=abc');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});
describe('GET /api/model-audit/scans/latest', () => {
it('should return 404 when no scans exist', async () => {
const response = await api.get('/api/model-audit/scans/latest');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'No scans found');
});
it('should return the latest scan', async () => {
const olderCreatedAt = Date.UTC(2026, 3, 20, 20, 13, 29);
await createTestAudit({
name: 'Older Scan',
createdAt: olderCreatedAt,
updatedAt: olderCreatedAt,
});
const latest = await createTestAudit({
name: 'Newer Scan',
createdAt: olderCreatedAt + 1000,
updatedAt: olderCreatedAt + 1000,
});
const response = await api.get('/api/model-audit/scans/latest');
expect(response.status).toBe(200);
expect(response.body.id).toBe(latest.id);
expect(response.body.name).toBe('Newer Scan');
expect(() => GetScanResponseSchema.parse(response.body)).not.toThrow();
});
});
describe('GET /api/model-audit/scans/:id', () => {
it('should return a scan by ID', async () => {
const audit = await createTestAudit({ name: 'My Scan' });
const response = await api.get(`/api/model-audit/scans/${audit.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(audit.id);
expect(response.body.name).toBe('My Scan');
expect(response.body.modelPath).toBe('/path/to/model.pkl');
expect(response.body.hasErrors).toBe(false);
expect(() => GetScanResponseSchema.parse(response.body)).not.toThrow();
});
it('should return 404 for non-existent scan', async () => {
const response = await api.get('/api/model-audit/scans/nonexistent-id-123');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'Model scan not found');
});
it('should expose stored provenance fields in scan responses', async () => {
const audit = await createTestAudit({
modelId: 'owner/model',
revisionSha: 'abc123',
contentHash: 'sha256:def456',
modelSource: 'huggingface',
sourceLastModified: 1_714_176_000_000,
scannerVersion: '0.2.30',
});
const response = await api.get(`/api/model-audit/scans/${audit.id}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
modelId: 'owner/model',
revisionSha: 'abc123',
contentHash: 'sha256:def456',
modelSource: 'huggingface',
sourceLastModified: 1_714_176_000_000,
scannerVersion: '0.2.30',
});
});
});
describe('DELETE /api/model-audit/scans/:id', () => {
it('should delete a scan by ID', async () => {
const audit = await createTestAudit({ name: 'To Delete' });
const response = await api.delete(`/api/model-audit/scans/${audit.id}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Model scan deleted successfully');
expect(() => DeleteScanResponseSchema.parse(response.body)).not.toThrow();
// Verify it's actually deleted
const getResponse = await api.get(`/api/model-audit/scans/${audit.id}`);
expect(getResponse.status).toBe(404);
});
it('should return 404 when deleting non-existent scan', async () => {
const response = await api.delete('/api/model-audit/scans/nonexistent-id-456');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error', 'Model scan not found');
});
});
});