docs: enterprise refactor plan with ralph specs
This commit is contained in:
187
tests/server/aiProviders.test.ts
Normal file
187
tests/server/aiProviders.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for AI providers with mocked HTTP calls.
|
||||
*/
|
||||
|
||||
import { ClaudeProvider } from '../../src/server/enrichment/ClaudeProvider';
|
||||
import { OpenAIProvider } from '../../src/server/enrichment/OpenAIProvider';
|
||||
import { OllamaProvider } from '../../src/server/enrichment/OllamaProvider';
|
||||
import type { IAnomaly, IEnrichmentContext } from '../../src/core/interfaces';
|
||||
|
||||
function makeAnomaly(): IAnomaly {
|
||||
return {
|
||||
id: 'anom_test',
|
||||
type: 'http_error',
|
||||
severity: 'high',
|
||||
observationId: 'obs_1',
|
||||
actionTrace: [],
|
||||
description: 'HTTP 500 error on /api/users',
|
||||
evidence: { rawErrors: ['GET /api/users → 500'] },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(): IEnrichmentContext {
|
||||
return {
|
||||
domSnapshot: '<body>Error</body>',
|
||||
httpLog: [{ url: '/api/users', status: 500, method: 'GET', durationMs: 120 }],
|
||||
consoleErrors: ['Uncaught TypeError: Cannot read properties of undefined'],
|
||||
actionTrace: [],
|
||||
pageTitle: 'Dashboard',
|
||||
url: 'http://localhost:3000/dashboard',
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_ENRICHMENT_JSON = JSON.stringify({
|
||||
rootCause: 'Null pointer in user data fetch',
|
||||
userImpact: 'Users cannot load the dashboard',
|
||||
suggestedFix: 'Add null check before accessing user.name',
|
||||
confidence: 'high',
|
||||
});
|
||||
|
||||
// ─── ClaudeProvider ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ClaudeProvider', () => {
|
||||
const origFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = origFetch;
|
||||
});
|
||||
|
||||
it('enriches anomaly with valid response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
content: [{ type: 'text', text: VALID_ENRICHMENT_JSON }],
|
||||
}),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new ClaudeProvider('test-api-key');
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
|
||||
expect(result.rootCause).toBe('Null pointer in user data fetch');
|
||||
expect(result.userImpact).toBe('Users cannot load the dashboard');
|
||||
expect(result.confidence).toBe('high');
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.generatedAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('throws on non-OK API response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve('Unauthorized'),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new ClaudeProvider('bad-key');
|
||||
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Anthropic API error: 401');
|
||||
});
|
||||
|
||||
it('falls back gracefully on non-JSON response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
content: [{ type: 'text', text: 'Sorry, I cannot help with that.' }],
|
||||
}),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new ClaudeProvider('test-key');
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
|
||||
expect(result.rootCause).toBeTruthy();
|
||||
expect(result.provider).toBe('claude');
|
||||
expect(result.confidence).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── OpenAIProvider ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('OpenAIProvider', () => {
|
||||
const origFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = origFetch;
|
||||
});
|
||||
|
||||
it('enriches anomaly with valid response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
choices: [{ message: { content: VALID_ENRICHMENT_JSON } }],
|
||||
}),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OpenAIProvider('test-openai-key');
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
|
||||
expect(result.rootCause).toBe('Null pointer in user data fetch');
|
||||
expect(result.confidence).toBe('high');
|
||||
expect(result.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('throws on non-OK API response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve('Rate limit exceeded'),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OpenAIProvider('test-key');
|
||||
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('OpenAI API error: 429');
|
||||
});
|
||||
|
||||
it('falls back on missing choices', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [] }),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OpenAIProvider('test-key');
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
expect(result.provider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── OllamaProvider ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('OllamaProvider', () => {
|
||||
const origFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = origFetch;
|
||||
});
|
||||
|
||||
it('enriches anomaly with valid response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: VALID_ENRICHMENT_JSON }),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OllamaProvider('http://localhost:11434');
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
|
||||
expect(result.rootCause).toBe('Null pointer in user data fetch');
|
||||
expect(result.provider).toBe('ollama');
|
||||
});
|
||||
|
||||
it('throws on non-OK Ollama response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OllamaProvider();
|
||||
await expect(provider.enrich(makeAnomaly(), makeContext())).rejects.toThrow('Ollama API error: 503');
|
||||
});
|
||||
|
||||
it('falls back on non-JSON response text', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: 'No valid JSON here' }),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const provider = new OllamaProvider();
|
||||
const result = await provider.enrich(makeAnomaly(), makeContext());
|
||||
expect(result.provider).toBe('ollama');
|
||||
expect(result.rootCause).toBeTruthy();
|
||||
});
|
||||
});
|
||||
75
tests/server/auth.test.ts
Normal file
75
tests/server/auth.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for API key authentication middleware and security features.
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/server/index';
|
||||
import { SessionStore } from '../../src/server/SessionStore';
|
||||
|
||||
function makeApp(apiKey?: string) {
|
||||
if (apiKey !== undefined) {
|
||||
process.env['ABE_API_KEY'] = apiKey;
|
||||
} else {
|
||||
delete process.env['ABE_API_KEY'];
|
||||
}
|
||||
const store = new SessionStore('./reports');
|
||||
return createApp(store);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['ABE_API_KEY'];
|
||||
});
|
||||
|
||||
describe('Auth middleware', () => {
|
||||
it('allows all requests in dev mode (no ABE_API_KEY set)', async () => {
|
||||
const app = makeApp(undefined);
|
||||
const res = await request(app).get('/api/sessions');
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when API key is missing', async () => {
|
||||
const app = makeApp('my-secret-key');
|
||||
const res = await request(app).get('/api/sessions');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when wrong API key provided', async () => {
|
||||
const app = makeApp('my-secret-key');
|
||||
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'wrong-key');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows request with correct API key', async () => {
|
||||
const app = makeApp('my-secret-key');
|
||||
const res = await request(app).get('/api/sessions').set('x-abe-api-key', 'my-secret-key');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health endpoints (no auth)', () => {
|
||||
it('GET /health requires no auth', async () => {
|
||||
const app = makeApp('super-secret');
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('GET /ready requires no auth', async () => {
|
||||
const app = makeApp('super-secret');
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent session limit', () => {
|
||||
it('returns 429 when limit exceeded', async () => {
|
||||
delete process.env['ABE_API_KEY'];
|
||||
const store = new SessionStore('./reports', undefined, undefined, 0);
|
||||
const app = createApp(store);
|
||||
const res = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({ url: 'http://localhost:3000' });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/Max concurrent/i);
|
||||
});
|
||||
});
|
||||
91
tests/server/cli.test.ts
Normal file
91
tests/server/cli.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for CLI flag parsing and exit code logic.
|
||||
* These test the logic functions extracted from the CLI, not the CLI process itself.
|
||||
*/
|
||||
|
||||
describe('CLI exit code logic', () => {
|
||||
const severityRank: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
|
||||
|
||||
function shouldFailOnSeverity(
|
||||
anomalies: Array<{ severity: string }>,
|
||||
threshold: string
|
||||
): boolean {
|
||||
const thresholdRank = severityRank[threshold] ?? 0;
|
||||
return anomalies.some((a) => (severityRank[a.severity] ?? 0) >= thresholdRank);
|
||||
}
|
||||
|
||||
it('exit 0 when no anomalies', () => {
|
||||
expect(shouldFailOnSeverity([], 'high')).toBe(false);
|
||||
});
|
||||
|
||||
it('exit 1 when anomaly at threshold', () => {
|
||||
expect(shouldFailOnSeverity([{ severity: 'high' }], 'high')).toBe(true);
|
||||
});
|
||||
|
||||
it('exit 1 when anomaly above threshold', () => {
|
||||
expect(shouldFailOnSeverity([{ severity: 'critical' }], 'high')).toBe(true);
|
||||
});
|
||||
|
||||
it('exit 0 when anomaly below threshold', () => {
|
||||
expect(shouldFailOnSeverity([{ severity: 'low' }], 'high')).toBe(false);
|
||||
});
|
||||
|
||||
it('exit 0 when medium anomaly and threshold is high', () => {
|
||||
expect(shouldFailOnSeverity([{ severity: 'medium' }], 'high')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JUnit XML generation', () => {
|
||||
function buildJunit(
|
||||
anomalies: Array<{ id: string; type: string; severity: string; description: string }>,
|
||||
url: string
|
||||
): string {
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
const cases = anomalies
|
||||
.map(
|
||||
(a) =>
|
||||
` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
|
||||
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
|
||||
` </testcase>`
|
||||
)
|
||||
.join('\n');
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalies.length}" failures="${anomalies.length}">\n` +
|
||||
cases + '\n' +
|
||||
`</testsuite>\n`
|
||||
);
|
||||
}
|
||||
|
||||
it('generates valid XML with one anomaly', () => {
|
||||
const xml = buildJunit(
|
||||
[{ id: 'a1', type: 'http_error', severity: 'high', description: 'Server error on form' }],
|
||||
'http://localhost:3000'
|
||||
);
|
||||
expect(xml).toContain('<testsuite');
|
||||
expect(xml).toContain('<failure');
|
||||
expect(xml).toContain('http_error');
|
||||
expect(xml).toContain('Server error on form');
|
||||
});
|
||||
|
||||
it('generates empty testsuite for no anomalies', () => {
|
||||
const xml = buildJunit([], 'http://localhost:3000');
|
||||
expect(xml).toContain('tests="0"');
|
||||
expect(xml).not.toContain('<failure');
|
||||
});
|
||||
|
||||
it('escapes XML special characters', () => {
|
||||
const xml = buildJunit(
|
||||
[{ id: 'a1', type: 'http_error', severity: 'high', description: '<script>alert(1)</script>' }],
|
||||
'http://app.com'
|
||||
);
|
||||
expect(xml).not.toContain('<script>');
|
||||
expect(xml).toContain('<script>');
|
||||
});
|
||||
});
|
||||
131
tests/server/notifications.test.ts
Normal file
131
tests/server/notifications.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Unit tests for SlackNotifier, WebhookNotifier, and NotificationService.
|
||||
* HTTP calls are mocked via jest.spyOn on global fetch.
|
||||
*/
|
||||
|
||||
import { SlackNotifier } from '../../src/server/notifications/SlackNotifier';
|
||||
import { WebhookNotifier } from '../../src/server/notifications/WebhookNotifier';
|
||||
import { NotificationService } from '../../src/server/notifications/NotificationService';
|
||||
import { IAnomaly } from '../../src/core/interfaces';
|
||||
|
||||
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
|
||||
return {
|
||||
id: 'anom_1',
|
||||
type: 'http_error',
|
||||
severity: 'high',
|
||||
observationId: 'obs_1',
|
||||
actionTrace: [],
|
||||
description: 'Test anomaly',
|
||||
evidence: {},
|
||||
timestamp: 1000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(ok: boolean, text = 'ok') {
|
||||
return jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
text: () => Promise.resolve(text),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── SlackNotifier ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SlackNotifier', () => {
|
||||
it('sends a POST to the Slack webhook URL', async () => {
|
||||
const spy = mockFetch(true);
|
||||
const notifier = new SlackNotifier('https://hooks.slack.com/test');
|
||||
await notifier.send(makeAnomaly(), 'sess_1', 'http://app.com');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = spy.mock.calls[0]!;
|
||||
expect(url).toBe('https://hooks.slack.com/test');
|
||||
expect((opts as RequestInit).method).toBe('POST');
|
||||
const body = JSON.parse((opts as RequestInit).body as string);
|
||||
expect(body.blocks).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws when Slack returns non-200', async () => {
|
||||
mockFetch(false, 'channel_not_found');
|
||||
const notifier = new SlackNotifier('https://hooks.slack.com/test');
|
||||
await expect(notifier.send(makeAnomaly(), 'sess_1', 'http://app.com')).rejects.toThrow('500');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WebhookNotifier ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('WebhookNotifier', () => {
|
||||
it('sends a POST with anomaly JSON to webhook URL', async () => {
|
||||
const spy = mockFetch(true);
|
||||
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
|
||||
await notifier.send(makeAnomaly());
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = spy.mock.calls[0]!;
|
||||
expect(url).toBe('https://myapp.com/webhooks/abe');
|
||||
expect((opts as RequestInit).method).toBe('POST');
|
||||
const body = JSON.parse((opts as RequestInit).body as string);
|
||||
expect(body.id).toBe('anom_1');
|
||||
});
|
||||
|
||||
it('throws when webhook returns non-200', async () => {
|
||||
mockFetch(false, 'bad gateway');
|
||||
const notifier = new WebhookNotifier('https://myapp.com/webhooks/abe');
|
||||
await expect(notifier.send(makeAnomaly())).rejects.toThrow('500');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NotificationService ─────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationService', () => {
|
||||
it('skips notifications below minSeverity', async () => {
|
||||
const spy = mockFetch(true);
|
||||
const service = new NotificationService({
|
||||
webhookUrl: 'https://myapp.com/hooks',
|
||||
minSeverity: 'high',
|
||||
});
|
||||
await service.notify(makeAnomaly({ severity: 'low' }), 'sess_1', 'http://app.com');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends notification at or above minSeverity', async () => {
|
||||
const spy = mockFetch(true);
|
||||
const service = new NotificationService({
|
||||
webhookUrl: 'https://myapp.com/hooks',
|
||||
minSeverity: 'high',
|
||||
});
|
||||
await service.notify(makeAnomaly({ severity: 'high' }), 'sess_1', 'http://app.com');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls persister with success record on successful send', async () => {
|
||||
mockFetch(true);
|
||||
const persisted: unknown[] = [];
|
||||
const service = new NotificationService({
|
||||
webhookUrl: 'https://myapp.com/hooks',
|
||||
minSeverity: 'low',
|
||||
persister: (r) => persisted.push(r),
|
||||
});
|
||||
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect((persisted[0] as { status: string }).status).toBe('success');
|
||||
});
|
||||
|
||||
it('calls persister with failed record on send failure', async () => {
|
||||
mockFetch(false, 'error');
|
||||
jest.useFakeTimers();
|
||||
const persisted: unknown[] = [];
|
||||
const service = new NotificationService({
|
||||
webhookUrl: 'https://myapp.com/hooks',
|
||||
minSeverity: 'low',
|
||||
persister: (r) => persisted.push(r),
|
||||
});
|
||||
await service.notify(makeAnomaly({ severity: 'medium' }), 'sess_1', 'http://app.com');
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect((persisted[0] as { status: string }).status).toBe('failed');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
151
tests/server/scheduler.test.ts
Normal file
151
tests/server/scheduler.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Tests for SchedulerService: cron parsing, schedule registration, skip when session active.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { ScheduleRepository } from '../../src/db/ScheduleRepository';
|
||||
import { SchedulerService } from '../../src/server/scheduler/SchedulerService';
|
||||
import { SessionStore } from '../../src/server/SessionStore';
|
||||
|
||||
function makeDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('SchedulerService', () => {
|
||||
let db: Database.Database;
|
||||
let repo: ScheduleRepository;
|
||||
let store: SessionStore;
|
||||
let scheduler: SchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
repo = new ScheduleRepository(db);
|
||||
store = new SessionStore('./reports');
|
||||
scheduler = new SchedulerService(repo, store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('starts without error when no schedules exist', () => {
|
||||
expect(() => scheduler.start()).not.toThrow();
|
||||
});
|
||||
|
||||
it('registers a valid cron job', () => {
|
||||
repo.create({
|
||||
id: 'sched_1',
|
||||
name: 'Daily check',
|
||||
url: 'http://example.com',
|
||||
configJson: '{}',
|
||||
cronExpression: '0 2 * * *',
|
||||
enabled: true,
|
||||
});
|
||||
expect(() => scheduler.register(repo.findById('sched_1')!)).not.toThrow();
|
||||
});
|
||||
|
||||
it('skips invalid cron expressions', () => {
|
||||
repo.create({
|
||||
id: 'sched_bad',
|
||||
name: 'Bad',
|
||||
url: 'http://x.com',
|
||||
configJson: '{}',
|
||||
cronExpression: 'not-valid-cron',
|
||||
enabled: true,
|
||||
});
|
||||
expect(() => scheduler.register(repo.findById('sched_bad')!)).not.toThrow();
|
||||
});
|
||||
|
||||
it('unregisters a schedule job', () => {
|
||||
repo.create({
|
||||
id: 'sched_2',
|
||||
name: 'Test',
|
||||
url: 'http://example.com',
|
||||
configJson: '{}',
|
||||
cronExpression: '0 * * * *',
|
||||
enabled: true,
|
||||
});
|
||||
scheduler.register(repo.findById('sched_2')!);
|
||||
expect(() => scheduler.unregister('sched_2')).not.toThrow();
|
||||
});
|
||||
|
||||
it('computeNextRunAt returns a future timestamp for valid cron', () => {
|
||||
const nextRun = SchedulerService.computeNextRunAt('0 2 * * *');
|
||||
expect(nextRun).not.toBeNull();
|
||||
expect(nextRun!).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('computeNextRunAt returns null for invalid cron', () => {
|
||||
expect(SchedulerService.computeNextRunAt('not-valid')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not register job for disabled schedule', () => {
|
||||
repo.create({
|
||||
id: 'sched_3',
|
||||
name: 'Disabled',
|
||||
url: 'http://example.com',
|
||||
configJson: '{}',
|
||||
cronExpression: '0 * * * *',
|
||||
enabled: false,
|
||||
});
|
||||
// Should not throw and job should not be registered
|
||||
expect(() => scheduler.register(repo.findById('sched_3')!)).not.toThrow();
|
||||
// Unregistering a non-existing job is also safe
|
||||
expect(() => scheduler.unregister('sched_3')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleRepository', () => {
|
||||
let db: Database.Database;
|
||||
let repo: ScheduleRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
repo = new ScheduleRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('creates and retrieves a schedule', () => {
|
||||
repo.create({
|
||||
id: 's1',
|
||||
name: 'Daily',
|
||||
url: 'http://test.com',
|
||||
configJson: '{"maxStates":10}',
|
||||
cronExpression: '0 2 * * *',
|
||||
});
|
||||
const record = repo.findById('s1');
|
||||
expect(record).toBeDefined();
|
||||
expect(record!.name).toBe('Daily');
|
||||
expect(record!.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('findAll returns all schedules', () => {
|
||||
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
|
||||
repo.create({ id: 's2', name: 'B', url: 'http://b.com', configJson: '{}', cronExpression: '0 2 * * *' });
|
||||
expect(repo.findAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('findAll(true) returns only enabled schedules', () => {
|
||||
repo.create({ id: 's1', name: 'Enabled', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *', enabled: true });
|
||||
repo.create({ id: 's2', name: 'Disabled', url: 'http://b.com', configJson: '{}', cronExpression: '0 * * * *', enabled: false });
|
||||
expect(repo.findAll(true)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates enabled field', () => {
|
||||
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
|
||||
repo.update('s1', { enabled: false });
|
||||
expect(repo.findById('s1')!.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a schedule', () => {
|
||||
repo.create({ id: 's1', name: 'A', url: 'http://a.com', configJson: '{}', cronExpression: '0 * * * *' });
|
||||
repo.delete('s1');
|
||||
expect(repo.findById('s1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
224
tests/server/server.test.ts
Normal file
224
tests/server/server.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Integration tests for the ABE API server.
|
||||
* Uses supertest to hit the Express app directly (no real browser).
|
||||
* The SessionStore is given a mock that never calls Playwright.
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/server/index';
|
||||
import { SessionStore, SessionRecord } from '../../src/server/SessionStore';
|
||||
import { IAnomaly } from '../../src/core/interfaces';
|
||||
|
||||
// ─── Mock SessionStore ────────────────────────────────────────────────────────
|
||||
|
||||
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
|
||||
return {
|
||||
id: 'anom_test1',
|
||||
type: 'http_error',
|
||||
severity: 'high',
|
||||
observationId: 'obs_1',
|
||||
actionTrace: [],
|
||||
description: 'HTTP 500 on form submit',
|
||||
evidence: {},
|
||||
timestamp: 1000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<SessionRecord> = {}): SessionRecord {
|
||||
return {
|
||||
sessionId: 'sess_1',
|
||||
url: 'http://localhost:3000',
|
||||
seed: 42,
|
||||
maxStates: 50,
|
||||
status: 'running',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
statesVisited: 5,
|
||||
anomaliesFound: 1,
|
||||
anomalies: [makeAnomaly()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MockSessionStore extends SessionStore {
|
||||
private _sessions: SessionRecord[];
|
||||
|
||||
constructor(sessions: SessionRecord[] = []) {
|
||||
super('./reports');
|
||||
this._sessions = sessions;
|
||||
}
|
||||
|
||||
getAllSessions() { return this._sessions; }
|
||||
getSession(id: string) { return this._sessions.find((s) => s.sessionId === id); }
|
||||
getAllAnomalies(sessionId?: string, severity?: string) {
|
||||
return this._sessions
|
||||
.flatMap((s) => s.anomalies)
|
||||
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
|
||||
.filter((a) => !severity || a.severity === severity);
|
||||
}
|
||||
getAnomaly(id: string) {
|
||||
return this._sessions.flatMap((s) => s.anomalies).find((a) => a.id === id);
|
||||
}
|
||||
findSessionForAnomaly(anomalyId: string) {
|
||||
return this._sessions.find((s) => s.anomalies.some((a) => a.id === anomalyId))?.sessionId;
|
||||
}
|
||||
screenshotPath(_anomalyId: string) { return undefined; }
|
||||
stopSession(id: string) {
|
||||
const s = this.getSession(id);
|
||||
if (!s || s.status !== 'running') return false;
|
||||
s.status = 'stopped';
|
||||
return true;
|
||||
}
|
||||
async startSession(params: { url: string; seed: number; maxStates: number }) {
|
||||
const record = makeSession({
|
||||
sessionId: `sess_new`,
|
||||
url: params.url,
|
||||
seed: params.seed,
|
||||
maxStates: params.maxStates,
|
||||
status: 'running',
|
||||
anomalies: [],
|
||||
anomaliesFound: 0,
|
||||
});
|
||||
this._sessions.push(record);
|
||||
return record;
|
||||
}
|
||||
async replayAnomaly(anomalyId: string) {
|
||||
if (!this.getAnomaly(anomalyId)) throw new Error('Anomaly not found');
|
||||
return `replay_${anomalyId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
it('returns empty array when no sessions', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).get('/api/sessions');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns list of sessions', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).get('/api/sessions');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].sessionId).toBe('sess_1');
|
||||
expect(res.body[0].status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:sessionId', () => {
|
||||
it('returns session detail', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).get('/api/sessions/sess_1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sessionId).toBe('sess_1');
|
||||
expect(res.body.seed).toBe(42);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).get('/api/sessions/no_such');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions', () => {
|
||||
it('creates a new session', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app)
|
||||
.post('/api/sessions')
|
||||
.send({ url: 'http://localhost:3000', seed: 1, maxStates: 10 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBeDefined();
|
||||
expect(res.body.status).toBe('running');
|
||||
});
|
||||
|
||||
it('returns 400 when url is missing', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).post('/api/sessions').send({ seed: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/sessions/:sessionId', () => {
|
||||
it('stops a running session', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).delete('/api/sessions/sess_1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).delete('/api/sessions/no_such');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/anomalies', () => {
|
||||
it('returns all anomalies', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).get('/api/anomalies');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].id).toBe('anom_test1');
|
||||
expect(res.body[0].severity).toBe('high');
|
||||
});
|
||||
|
||||
it('filters by severity', async () => {
|
||||
const session = makeSession({
|
||||
anomalies: [
|
||||
makeAnomaly({ id: 'anom_h', severity: 'high' }),
|
||||
makeAnomaly({ id: 'anom_l', severity: 'low' }),
|
||||
],
|
||||
anomaliesFound: 2,
|
||||
});
|
||||
const app = createApp(new MockSessionStore([session]));
|
||||
const res = await request(app).get('/api/anomalies?severity=low');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].id).toBe('anom_l');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/anomalies/:anomalyId', () => {
|
||||
it('returns anomaly detail', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).get('/api/anomalies/anom_test1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('http_error');
|
||||
expect(res.body.description).toBe('HTTP 500 on form submit');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown anomaly', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).get('/api/anomalies/no_such');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/anomalies/:anomalyId/screenshot', () => {
|
||||
it('returns 404 when no screenshot exists', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).get('/api/anomalies/anom_test1/screenshot');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/anomalies/:anomalyId/replay', () => {
|
||||
it('returns replayId and running status', async () => {
|
||||
const app = createApp(new MockSessionStore([makeSession()]));
|
||||
const res = await request(app).post('/api/anomalies/anom_test1/replay');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.replayId).toBeDefined();
|
||||
expect(res.body.status).toBe('running');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown anomaly', async () => {
|
||||
const app = createApp(new MockSessionStore([]));
|
||||
const res = await request(app).post('/api/anomalies/no_such/replay');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user