Assessor_2.0/tests/features/db-structure-flow.test.tsx

340 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import { act, createElement } from 'react';
import { readFileSync } from 'node:fs';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { DbStructureTable } from '../../src/domain/db-structure';
import { DbStructurePanel } from '../../src/features/db-structure/DbStructurePanel';
import { EngineDataFlow } from '../../src/features/db-structure/EngineDataFlow';
import { DATA_FLOW_RELATIONSHIPS } from '../../src/features/db-structure/DataFlowRelationships';
import { buildDbStructureFlow, dbStructureNeighbours, DB_FLOW_STEPS } from '../../src/features/db-structure/db-structure-flow';
const table = (name: string, references: string[] = [], rows: number | null = null): DbStructureTable => ({
name: `assessor_${name}`, layer: 'other', approximateRowCount: rows,
columns: [{ name: 'id', dataType: 'text', nullable: false }],
foreignKeys: references.map(target => ({ column: `${target}_id`, referencedTable: `assessor_${target}`, referencedColumn: 'id' }))
});
describe('DB Structure data flow', () => {
afterEach(() => vi.restoreAllMocks());
it.each(['dark', 'light'])('keeps every engine framed after deselection without highlighting arrows in %s', async theme => {
document.documentElement.dataset.theme = theme;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(createElement(EngineDataFlow)));
const engines = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node.is-engine')];
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance', 'comparison']);
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4, comparison: 2 };
// Remove the initial Core selection; identity frames must remain independently.
await act(async () => engines[0].click());
const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related');
expect(highlighted()).toHaveLength(0);
for (const engine of engines) {
expect(engine.classList.contains('is-engine')).toBe(true);
expect(engine.getAttribute('aria-pressed')).toBe('false');
await act(async () => engine.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
expect(highlighted()).toHaveLength(0);
await act(async () => engine.click());
expect(engine.getAttribute('aria-pressed')).toBe('true');
const selectedPaths = [...highlighted()].map(path => path.getAttribute('d'));
expect(selectedPaths).toHaveLength(connectionCount[engine.dataset.node!]);
await act(async () => engine.click());
expect(highlighted()).toHaveLength(0);
expect(engine.classList.contains('is-engine')).toBe(true);
}
const css = readFileSync('src/styles/datahub.css', 'utf8');
// Engine identity must contrast with ordinary related-node borders in both themes.
expect(css).toMatch(/\.db-engine-flow__node\.is-engine\s*\{[^}]*outline:\s*var\(--bw-emphasis\) solid var\(--muted\)/);
expect(css).toMatch(/\.db-engine-flow__node\.is-engine\s*\{[^}]*border-left:\s*var\(--marker-width\) solid var\(--muted\)/);
expect(css).toMatch(/\.db-engine-flow__node\[aria-pressed="true"\]\s*\{[^}]*outline:[^;]*var\(--marking-color\)/);
expect(css).toMatch(/\.db-engine-flow__edges > path\.is-related\s*\{[^}]*stroke:\s*var\(--marking-color\)/);
} finally { await act(async () => root.unmount()); }
});
it('selects engine modules, highlights only their directed connections and shows the implemented Context publication', async () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => root.render(createElement(EngineDataFlow)));
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(19);
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4);
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
const context = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Context Engine'))!;
await act(async () => context.click());
expect(context.getAttribute('aria-pressed')).toBe('true');
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Context Engine');
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(4);
// The Management Report consumes the published Context results, so no relationship is drawn as planned.
expect(container.querySelector('.db-engine-flow__edges > path.is-planned')).toBeNull();
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('Versioned Context publication for the Management Report');
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
await act(async () => context.click());
expect(container.querySelector('.db-engine-flow__inspector')).toBeNull();
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(0);
// The Compliance Engine reads through the port, owns its tables and reaches the UI only through extension points.
const compliance = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Compliance Engine'))!;
await act(async () => compliance.click());
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Compliance Engine');
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Compliance Engine', 'Compliance Engine ↔ Compliance tables',
'Compliance Engine ↔ Retrieval & model', 'Compliance Engine → ISO mapping in the UI']);
await act(async () => compliance.click());
// The Remediation Engine reads through the port and the published Context, owns its schema and reaches the UI through extension points.
const remediation = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Remediation Engine'))!;
await act(async () => remediation.click());
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Remediation Engine');
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Remediation Engine', 'Context Engine → Remediation Engine',
'Remediation Engine ↔ Remediation tables', 'Remediation Engine → Work packages in the UI']);
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation');
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
await act(async () => remediation.click());
// The Comparison Engine reads both timeslots through the port and reaches the UI only through extension points.
const comparison = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Comparison Engine'))!;
await act(async () => comparison.click());
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Comparison Engine', 'Comparison Engine → Comparison in the UI']);
await act(async () => comparison.click());
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
await act(async () => dictionary.querySelector('summary')!.click());
expect(dictionary.open).toBe(true);
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (19)');
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(19);
act(() => root.unmount());
});
it('assigns each table to exactly one step and never hides an unassigned table', () => {
const assigned = DB_FLOW_STEPS.flatMap(step => step.tables);
expect(new Set(assigned).size).toBe(assigned.length);
const flow = buildDbStructureFlow([table('projects'), table('gpo_backup_instances', ['projects']), table('synthetic_new_table')]);
expect(flow.steps.map(step => step.id)).toEqual(['foundation', 'gpo-inventory']);
expect(flow.other.map(item => item.name)).toEqual(['assessor_synthetic_new_table']);
});
it('keeps a schema-qualified engine table visible under Other tables', () => {
// Engine-owned schemas are listed as schema.table; no processing step claims them.
const flow = buildDbStructureFlow([table('projects'), table('remediation.package_snapshots')]);
expect(flow.steps.map(step => step.id)).toEqual(['foundation']);
expect(flow.other.map(item => item.name)).toEqual(['assessor_remediation.package_snapshots']);
});
it('lists outgoing and incoming relations of the selected table', () => {
const tables = [table('projects'), table('assessments', ['projects']), table('reviews', ['assessments', 'projects'])];
expect(dbStructureNeighbours(tables, 'assessor_assessments')).toEqual({ outgoing: ['assessor_projects'], incoming: ['assessor_reviews'] });
});
it('keeps every arrow outside tiles after selecting a tile and resizing to a stacked flow', async () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.spyOn(AssessorApiClient.prototype, 'getDatabaseStructure').mockResolvedValue({
generatedAt: '2026-10-02T10:00:00.000Z', persistence: 'postgresql',
tables: DB_FLOW_STEPS.flatMap(step => step.tables.map(name => table(name)))
});
const desktop: Record<string, [number, number, number, number]> = {
foundation: [454, 100, 358, 100], upload: [454, 296, 358, 180],
'scanner-processing': [0, 700, 358, 200], 'scanner-mapping': [0, 1096, 358, 100],
'scanner-canonical': [0, 1392, 358, 180], 'scanner-findings': [0, 1768, 358, 220],
'gpo-inventory': [454, 700, 358, 100], 'gpo-identity': [454, 996, 358, 200],
'gpo-aggregation': [454, 1392, 358, 180], 'gpo-evaluation': [454, 1768, 358, 230],
'gpo-findings': [454, 2194, 358, 140], 'reference-baselines': [908, 700, 358, 280],
'result-materialization': [454, 2530, 358, 180], 'result-db': [227, 2906, 358, 460],
review: [681, 2906, 358, 140], comparison: [454, 3562, 358, 240], other: [454, 3998, 358, 100]
};
let positions = desktop;
let width = 1266;
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('db-flow')) return new DOMRect(0, 0, width, 7000);
const box = positions[this.dataset.flowId ?? ''];
return box ? new DOMRect(...box) : originalBounds.call(this);
});
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => {
root.render(createElement(DbStructurePanel, { backendStatus: 'connected' }));
await new Promise(resolve => setTimeout(resolve, 0));
});
const assertClear = () => {
const paths = [...container.querySelectorAll('.db-flow__edges > path')];
expect(paths).toHaveLength(17);
const visibleBoxes = [...container.querySelectorAll<HTMLElement>('[data-flow-id]')]
.map(node => positions[node.dataset.flowId!]);
for (const path of paths) {
let x = 0, y = 0;
for (const command of path.getAttribute('d')!.matchAll(/([MHV])(-?[\d.]+)(?:\s+(-?[\d.]+))?/g)) {
const nextX = command[1] === 'V' ? x : Number(command[2]);
const nextY = command[1] === 'H' ? y : Number(command[1] === 'V' ? command[2] : command[3]);
if (command[1] !== 'M') for (const [left, top, boxWidth, height] of visibleBoxes) {
const intersects = Math.max(x, nextX) > left && Math.min(x, nextX) < left + boxWidth &&
Math.max(y, nextY) > top && Math.min(y, nextY) < top + height;
expect(intersects, `${path.getAttribute('data-relationship')} crosses tile at ${left},${top}`).toBe(false);
}
x = nextX; y = nextY;
}
const relation = DATA_FLOW_RELATIONSHIPS.find(([from, to]) => `${from}-${to}` === path.getAttribute('data-relationship'))!;
const [left, top, boxWidth, height] = positions[relation[1]];
const distance = Math.hypot(Math.max(left - x, 0, x - left - boxWidth), Math.max(top - y, 0, y - top - height));
expect(distance).toBeCloseTo(4);
}
};
assertClear();
const select = container.querySelector<HTMLButtonElement>('[data-flow-id="scanner-findings"] .db-flow__select')!;
await act(async () => select.click());
let y = 100;
positions = Object.fromEntries(Object.entries(desktop).map(([id, box]) => {
const stacked: [number, number, number, number] = [164, y, 358, box[3]];
y += box[3] + 96;
return [id, stacked];
}));
width = 686;
await act(async () => window.dispatchEvent(new Event('resize')));
assertClear();
expect(select.getAttribute('aria-pressed')).toBe('true');
expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(4);
expect(document.querySelector('.floating-data-window')).toBeNull();
act(() => root.unmount());
});
it('opens the selected flow table in a popup and returns to the unchanged flow on close', async () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.spyOn(AssessorApiClient.prototype, 'getDatabaseStructure').mockResolvedValue({
generatedAt: '2026-09-25T10:00:00.000Z', persistence: 'postgresql',
tables: [table('projects', [], 0), table('import_artifacts', ['projects'], 12), table('scan_runs', ['import_artifacts']),
table('gpo_backup_instances', ['import_artifacts']), table('microsoft_security_baselines'), table('reviews', ['projects']),
table('finding_occurrences'), table('policy_finding_occurrences'), table('policy_evaluation_results'),
table('result_db_rows'), table('comparisons')]
});
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => {
root.render(createElement(DbStructurePanel, { backendStatus: 'connected' }));
await new Promise(resolve => setTimeout(resolve, 0));
});
expect([...container.querySelectorAll('.db-flow__lane-title')].map(item => item.textContent)).toEqual(['Scanner reports', 'GPO backups', 'Reference data']);
expect(container.querySelector('.db-structure-intro-copy')).toBeNull();
expect(container.querySelector('.db-flow__header p')).toBeNull();
expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow');
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']);
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(19);
expect(relations?.textContent).toContain('Core read-only port');
expect(relations?.textContent).not.toContain('Planned');
expect(relations?.textContent).toContain('Versioned Context publication for the Management Report');
await act(async () => {
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(6);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(19);
expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema');
expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step');
const chips = [...container.querySelectorAll<HTMLButtonElement>('.db-flow__table')];
const flowStepCount = container.querySelectorAll('.db-flow__step').length;
// Permanent acceptance: missing consumer/merge/reference arrows remain visible
// through table selection, popup navigation and closing the popup.
const edgeIds = () => [...container.querySelectorAll('.db-flow__edges > path')]
.map(edge => edge.getAttribute('data-relationship'));
const requiredEdges = ['scanner-findings-result-materialization', 'gpo-findings-result-materialization',
'result-materialization-result-db', 'scanner-findings-review', 'gpo-findings-review',
'scanner-findings-comparison', 'reference-baselines-gpo-evaluation'];
expect(edgeIds()).not.toContain('gpo-findings-comparison');
expect(edgeIds()).toEqual(expect.arrayContaining(requiredEdges));
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const id = this.dataset.flowId;
const positions: Record<string, [number, number, number, number]> = {
'scanner-findings': [0, 100, 358, 100], 'gpo-findings': [400, 180, 358, 200],
'result-materialization': [400, 450, 358, 150], 'review': [600, 700, 358, 150],
};
const box = positions[id ?? ''];
return box ? new DOMRect(...box) : originalBounds.call(this);
});
await act(async () => window.dispatchEvent(new Event('resize')));
const mergePath = container.querySelector('[data-relationship="scanner-findings-result-materialization"]')!.getAttribute('d')!;
// The cross-lane horizontal segment must clear the taller GPO tile.
const crossLaneY = Number(mergePath.match(/V([\d.]+) H/)![1]);
expect(crossLaneY).toBeGreaterThan(380);
expect(crossLaneY).toBeLessThan(450);
for (const edge of container.querySelectorAll('.db-flow__edges > path')) {
expect(edge.getAttribute('marker-end')).toMatch(/^url\(#/);
expect(edge.querySelector('title')?.textContent).toContain('→');
}
// A zero estimate is not shown as a row count.
expect(chips.find(chip => chip.textContent?.startsWith('projects'))?.textContent).toBe('projects');
expect(chips.find(chip => chip.textContent?.startsWith('import_artifacts'))?.textContent).toBe('import_artifacts~12');
await act(async () => chips.find(chip => chip.textContent?.startsWith('import_artifacts'))!.click());
expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(1);
expect(container.querySelector('.db-flow__edges [data-relationship="foundation-upload"]')?.classList.contains('is-related')).toBe(true);
expect([...new Set([...container.querySelectorAll('.db-flow__table[data-related]')]
.map(chip => `${chip.textContent}:${chip.getAttribute('data-related')}`))])
.toEqual(['projects:outgoing', 'scan_runs:incoming', 'gpo_backup_instances:incoming']);
expect(container.querySelector('.db-flow__detail')).toBeNull();
const popup = document.querySelector<HTMLElement>('.floating-data-window[role="dialog"]');
expect(popup?.textContent).toContain('assessor_import_artifacts');
expect(popup?.querySelector('[aria-label="Columns in assessor_import_artifacts"]')).not.toBeNull();
expect(popup?.textContent).toContain('Referenced by');
await act(async () => [...popup!.querySelectorAll<HTMLButtonElement>('.db-flow__links button')]
.find(button => button.textContent === 'scan_runs')!.click());
expect(popup?.querySelector('h2')?.textContent).toBe('assessor_scan_runs');
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(document.querySelector('.floating-data-window')).toBeNull();
expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(0);
expect(container.querySelectorAll('.db-flow__step')).toHaveLength(flowStepCount);
expect(edgeIds()).toEqual(expect.arrayContaining(requiredEdges));
expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(0);
const findingTile = container.querySelector<HTMLElement>('[data-flow-id="scanner-findings"]')!;
const findingSelect = findingTile.querySelector<HTMLButtonElement>('.db-flow__select')!;
await act(async () => findingTile.querySelector('p')!.click());
expect(findingTile.dataset.selected).toBe('true');
expect(findingSelect.getAttribute('aria-pressed')).toBe('true');
const markedPaths = [...container.querySelectorAll('.db-flow__edges > path.is-related')];
expect(markedPaths.map(path => path.getAttribute('data-relationship')).sort()).toEqual([
'scanner-findings-comparison', 'scanner-findings-result-materialization', 'scanner-findings-review'
]);
for (const path of markedPaths) {
const markerId = path.getAttribute('marker-end')!.slice(5, -1);
expect(container.querySelector(`[id="${markerId}"] path`)?.classList.contains('is-related')).toBe(true);
}
// Tile selection does not open a table popup; chips keep their own action.
expect(document.querySelector('.floating-data-window')).toBeNull();
await act(async () => findingTile.querySelector<HTMLButtonElement>('.db-flow__table')!.click());
expect(document.querySelector('.floating-data-window')).not.toBeNull();
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(findingTile.dataset.selected).toBe('true');
await act(async () => findingSelect.click());
expect(findingTile.dataset.selected).toBe('false');
expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(0);
// The layered table dictionary stays available below the flow.
expect(container.querySelector('details.db-structure-dictionary summary')?.textContent).toBe('Table dictionary by layer');
act(() => root.unmount());
});
});
describe('DB Structure read views', () => {
it('assigns stored disclosed Result DB rows to their Core-owned materialization step', () => {
const flow = buildDbStructureFlow([table('result_db_rows'), table('result_db_state')]);
expect(flow.steps.map(step => step.id)).toEqual(['result-materialization']);
expect(flow.other).toEqual([]);
});
it('shows the Result DB as a read view over tables owned by the processing steps', async () => {
const { buildDbStructureViews } = await import('../../src/features/db-structure/db-structure-flow');
const views = buildDbStructureViews([table('finding_occurrences'), table('policy_evaluation_results'), table('reviews')]);
expect(views.map(view => [view.title, view.resolved.map(item => item.name)])).toEqual([
['Result DB', ['assessor_finding_occurrences', 'assessor_policy_evaluation_results']]
]);
// Owning steps stay unique: a read view never claims a table.
expect(buildDbStructureFlow([table('finding_occurrences')]).other).toEqual([]);
});
});