69 lines
3.7 KiB
TypeScript
69 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { aiOverviewResultValidationErrors, aiOverviewScopeKey, normalizeAiOverviewScope } from '../../src/domain/ai-overview';
|
|
import { generateAiOverview, splitPrivacySafeInput, type PrivacySafeInput } from '../../server/ai/ai-overview-service';
|
|
import { validAiOverviewResult } from '../fixtures/ai-overview';
|
|
|
|
describe('AI overview contract', () => {
|
|
it('normalizes timeslot identity deterministically', () => {
|
|
const scope = normalizeAiOverviewScope({ assessmentIds: ['b', 'a', 'a'], scanRunIds: ['2', '1'] });
|
|
expect(scope).toEqual({ assessmentIds: ['a', 'b'], scanRunIds: ['1', '2'] });
|
|
expect(aiOverviewScopeKey('project', scope)).toBe('project::assessments::a|b::scan-runs::1|2');
|
|
});
|
|
|
|
it('rejects invented source IDs and malformed roadmap phases', () => {
|
|
const result = validAiOverviewResult();
|
|
result.roadmap[2].phase = 4;
|
|
expect(aiOverviewResultValidationErrors(result, new Set(['OTHER']), new Set(['RULE-GAP'])))
|
|
.toEqual(expect.arrayContaining([expect.stringContaining('unknown source rule ID'), expect.stringContaining('roadmap[2].phase')]));
|
|
});
|
|
|
|
it('rejects direct identifiers returned by the model', () => {
|
|
const result = validAiOverviewResult();
|
|
result.executiveSummary = 'Contact admin@example.test at 192.0.2.10.';
|
|
expect(aiOverviewResultValidationErrors(result)).toContain('AI overview output contains an email address or IP address.');
|
|
});
|
|
|
|
it('splits only between complete privacy-safe array entries', () => {
|
|
const input: PrivacySafeInput = {
|
|
schemaVersion: 1, summary: { totalResults: 2 },
|
|
findings: [
|
|
{ id: 'RULE-1', title: 'A'.repeat(120), outcome: 'fail' },
|
|
{ id: 'RULE-2', title: 'B'.repeat(120), outcome: 'fail' }
|
|
],
|
|
notRunChecks: [], environmentFacts: []
|
|
};
|
|
const chunks = splitPrivacySafeInput(input, 350);
|
|
expect(chunks).toHaveLength(2);
|
|
expect(chunks.flatMap(chunk => chunk.findings).map(item => item.id)).toEqual(['RULE-1', 'RULE-2']);
|
|
expect(chunks.every(chunk => JSON.stringify(chunk).length <= 350)).toBe(true);
|
|
});
|
|
|
|
it('gives every chunk and the consolidation call the complete environment facts', async () => {
|
|
const environmentFacts = [
|
|
{ title: 'Domain controller count', property: 'domain.controller_count', value: 2, tool: 'pingcastle' },
|
|
{ title: 'User accounts', property: 'user.account_count', value: 120, tool: 'pingcastle' }
|
|
];
|
|
const input: PrivacySafeInput = {
|
|
schemaVersion: 1, summary: { totalResults: 4 },
|
|
findings: [1, 2, 3].map(index => ({ id: 'RULE-1', title: String(index).repeat(4000), outcome: 'fail' })),
|
|
notRunChecks: [], environmentFacts
|
|
};
|
|
const chunks = splitPrivacySafeInput(input, 10_000);
|
|
expect(chunks.length).toBeGreaterThan(1);
|
|
expect(chunks.every(chunk => JSON.stringify(chunk.environmentFacts) === JSON.stringify(environmentFacts))).toBe(true);
|
|
expect(chunks.flatMap(chunk => chunk.findings)).toHaveLength(3);
|
|
|
|
const payloads: Array<Record<string, unknown>> = [];
|
|
await generateAiOverview(input, {
|
|
client: { model: 'synthetic', complete: async (_prompt: string, value: string) => { payloads.push(JSON.parse(value)); return { value: validAiOverviewResult() }; } },
|
|
maximumChunkCharacters: 10_000, maximumCalls: 10,
|
|
findingIds: new Set(['RULE-1']), gapIds: new Set(['RULE-GAP']),
|
|
factProperties: new Set(environmentFacts.map(fact => fact.property)),
|
|
onCall: async () => {}
|
|
});
|
|
expect(payloads.length).toBe(chunks.length + 1);
|
|
expect(payloads.every(payload => JSON.stringify(payload.environmentFacts) === JSON.stringify(environmentFacts))).toBe(true);
|
|
expect(payloads.at(-1)).toHaveProperty('partialAnalyses');
|
|
});
|
|
});
|