322 lines
25 KiB
TypeScript
322 lines
25 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from 'react';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
import { DataHub } from '../../src/features/project-import/DataHub';
|
|
import { withReferenceReadiness } from '../../src/features/project-import/datahub-processing-readiness';
|
|
|
|
const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(),
|
|
listProjectImportFiles: vi.fn(), listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(),
|
|
getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn(), getReferenceRecognitionStatus: vi.fn(),
|
|
recognizeReferences: vi.fn(), refreshMitreCatalog: vi.fn(), listProjectAssessments: vi.fn(), loadAssessmentModel: vi.fn(),
|
|
saveProject: vi.fn(), getProject: vi.fn() }));
|
|
vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class {
|
|
health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects;
|
|
listProjectImportFiles = api.listProjectImportFiles; listProjectAssessmentHistory = api.listProjectAssessmentHistory;
|
|
listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; getTimeslotResultMetrics = api.getTimeslotResultMetrics;
|
|
queryUnifiedResultFacets = api.queryUnifiedResultFacets; getReferenceRecognitionStatus = api.getReferenceRecognitionStatus;
|
|
recognizeReferences = api.recognizeReferences;
|
|
refreshMitreCatalog = api.refreshMitreCatalog;
|
|
listProjectAssessments = api.listProjectAssessments; loadAssessmentModel = api.loadAssessmentModel;
|
|
saveProject = api.saveProject; getProject = api.getProject;
|
|
} }));
|
|
vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null }));
|
|
vi.mock('../../src/features/file-import/FileImport', () => ({ FileImport: () => null }));
|
|
vi.mock('../../src/features/project-import/GpoFolderImport', () => ({ GpoFolderImport: () => null }));
|
|
|
|
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
|
|
|
|
const projectId = 'synthetic-reference-client';
|
|
const readinessStyles = readFileSync('src/styles/datahub-redesign.css', 'utf8');
|
|
const referenceStatus = (pendingScans: number, indexedScans = 2) => ({ totalScans: 2, indexedScans, pendingScans,
|
|
catalogs: [{ framework: 'mitre-attack' as const, version: '19.2', sha256: 'synthetic-hash' }] });
|
|
function fixture() {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const date = '2026-01-01T00:00:00Z';
|
|
api.health.mockResolvedValue({ status: 'ok', repositoryRevision: 801 });
|
|
api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'],
|
|
techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } });
|
|
api.listProjects.mockResolvedValue([{ id: projectId, name: 'Synthetic client', attributes: {}, createdAt: date, updatedAt: date }]);
|
|
api.listProjectImportFiles.mockResolvedValue([]);
|
|
api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] });
|
|
api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId,
|
|
assessments: ['first', 'second'].map(id => ({ id, projectId, name: `Synthetic ${id}`, createdAt: date, updatedAt: date, effectiveAt: date })),
|
|
scanRuns: ['first', 'second'].map(id => ({ id: `${id}-run`, assessmentId: id, sequence: 1, label: 'Synthetic scan', importedAt: date,
|
|
tool: { id: 'synthetic', name: 'Synthetic scanner' } })) });
|
|
api.getTimeslotResultMetrics.mockResolvedValue({ all: 2, deviation: 1, passed: 1, notApplicable: 0, notRun: 0, error: 0 });
|
|
api.queryUnifiedResultFacets.mockResolvedValue({ total: 2, facets: [] });
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, 0));
|
|
api.recognizeReferences.mockImplementation(async () => {
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
|
|
return { processedScans: 2, unchangedScans: 0, references: 4 };
|
|
});
|
|
}
|
|
|
|
it.each([['dark', 0], ['dark', 2], ['light', 0], ['light', 2]] as const)('marks pending references red in %s (indexed=%i) and clears the warning after Manage recognition', async (theme, indexedScans) => {
|
|
document.documentElement.dataset.theme = theme;
|
|
fixture();
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2, indexedScans));
|
|
const changed = vi.fn();
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined} onProjectDataChanged={changed}/>));
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('reference-update');
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('reference-update');
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('title')).toContain('2 scans require reference recognition');
|
|
expect(readinessStyles).toMatch(/\.datahub-tenant-processing-dot\[data-status="reference-update"\][^{]*\{[^}]*background: var\(--state-offline\)/);
|
|
expect(readinessStyles).toMatch(/\.datahub-tenant-show\[data-status="reference-update"\][^{]*\{[^}]*border-color: var\(--state-offline\); color: var\(--state-offline\)/);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
const panel = host.querySelector('.tenant-detail-navigation .reference-recognition');
|
|
expect(panel?.nextElementSibling?.classList.contains('tenant-timeslots')).toBe(true);
|
|
expect(host.querySelector('.tenant-information-sheet .reference-recognition')).toBeNull();
|
|
expect(panel?.textContent).toContain('Catalog references');
|
|
const disclosure = panel!.querySelector('details')!;
|
|
expect(disclosure.open).toBe(true);
|
|
expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
|
|
await act(async () => disclosure.querySelector('summary')!.click());
|
|
expect(disclosure.open).toBe(false);
|
|
await act(async () => disclosure.querySelector('summary')!.click());
|
|
expect(disclosure.open).toBe(true);
|
|
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Please update');
|
|
expect(panel?.textContent).not.toContain('Recognize ATT&CK and D3FEND references in');
|
|
const button = panel!.querySelector<HTMLButtonElement>('button')!;
|
|
expect(button.textContent).toBe('Update references');
|
|
expect(button.disabled).toBe(false);
|
|
expect(button.dataset.attention).toBe('true');
|
|
expect(button.classList.contains('primary')).toBe(true);
|
|
await act(async () => button.click());
|
|
expect(api.recognizeReferences).toHaveBeenCalledExactlyOnceWith(projectId);
|
|
expect(changed).toHaveBeenCalledExactlyOnceWith(projectId);
|
|
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('Update successful');
|
|
expect(panel?.querySelector('[role="status"]')?.textContent).toContain('No action needed.');
|
|
expect(button.dataset.attention).toBe('false');
|
|
expect(disclosure.open).toBe(true);
|
|
expect(disclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
|
|
expect(disclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-edit-client')!.click());
|
|
expect(host.querySelector('.tenant-detail-navigation .reference-recognition')).toBe(panel);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
|
|
expect(host.querySelector('.datahub-tenant-show')).toBeNull();
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-select')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
expect(host.querySelector('.reference-recognition [role="status"]')?.textContent).toBe('Up to dateNo action needed.');
|
|
expect(host.querySelector('.reference-recognition-versions')?.textContent).toContain('ATT&CK 19.2');
|
|
const currentDisclosure = host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!;
|
|
expect(currentDisclosure.open).toBe(false);
|
|
expect(currentDisclosure.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
|
|
expect(host.querySelector('.reference-recognition-versions')?.closest('details')).toBeNull();
|
|
await act(async () => currentDisclosure.querySelector('summary')!.click());
|
|
expect(currentDisclosure.open).toBe(true);
|
|
expect(currentDisclosure.querySelector('[role="status"] .reference-recognition-dot')?.getAttribute('data-state')).toBe('ready');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('keeps a recognition failure red on return and allows a successful retry', async () => {
|
|
fixture();
|
|
api.recognizeReferences.mockRejectedValueOnce(new Error('Synthetic recognition failure'));
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
|
|
expect(host.querySelector('.reference-recognition [role="alert"]')?.textContent).toContain('Synthetic recognition failure');
|
|
expect(host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!.open).toBe(true);
|
|
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('error');
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('error');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('does not let a slow overview status overwrite successful recognition in the client panel', async () => {
|
|
fixture();
|
|
let resolveOld!: (status: ReturnType<typeof referenceStatus>) => void;
|
|
api.getReferenceRecognitionStatus.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }));
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('checking');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-select')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.reference-recognition button')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
|
|
await act(async () => resolveOld(referenceStatus(2, 0)));
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('keeps unavailable status visible and offers a working status retry in Manage', async () => {
|
|
fixture();
|
|
api.getReferenceRecognitionStatus.mockRejectedValueOnce(new Error('Synthetic unavailable')).mockRejectedValueOnce(new Error('Synthetic unavailable'));
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('review');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
const retry = [...host.querySelectorAll<HTMLButtonElement>('.reference-recognition button')].find(button => button.textContent === 'Retry status')!;
|
|
expect(host.querySelector<HTMLDetailsElement>('.reference-recognition-disclosure')!.open).toBe(true);
|
|
expect(host.querySelector('summary .reference-recognition-dot')?.getAttribute('data-state')).toBe('attention');
|
|
await act(async () => retry.click());
|
|
expect(host.querySelector<HTMLButtonElement>('.reference-recognition button')!.disabled).toBe(false);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-back')!.click());
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('reference-update');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('refreshes reference warnings after a catalog update and a changed import', async () => {
|
|
fixture();
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
|
|
api.refreshMitreCatalog.mockImplementation(async () => {
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(2));
|
|
return { updated: true, catalog: { version: '19.3', tacticOrder: ['Execution'],
|
|
techniques: [{ id: 'T1059', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } };
|
|
});
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
expect(host.querySelector('.datahub-tenant-show')).toBeNull();
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.mitre-library .datahub-maintenance-action')!.click());
|
|
expect(host.querySelector('.datahub-tenant-show')?.getAttribute('data-status')).toBe('reference-update');
|
|
api.getReferenceRecognitionStatus.mockResolvedValue(referenceStatus(0));
|
|
await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed')));
|
|
expect(host.querySelector('.datahub-tenant-processing-dot')?.getAttribute('data-status')).toBe('ready');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('preserves scanner and GPO error priority alongside a reference update', () => {
|
|
const result = withReferenceReadiness({ state: 'error', label: 'Synthetic GPO error.' },
|
|
{ state: 'ready', status: { ...referenceStatus(2), catalogs: [] } });
|
|
expect(result.state).toBe('error');
|
|
expect(result.label).toContain('Synthetic GPO error.');
|
|
expect(result.label).toContain('2 scans require reference recognition');
|
|
});
|
|
|
|
it('prioritizes a red reference update over a yellow processing update and restores the remaining task after recognition', () => {
|
|
const processing = { state: 'update' as const, label: 'Synthetic profile update.' };
|
|
const pending = withReferenceReadiness(processing, { state: 'ready', status: referenceStatus(2) });
|
|
expect(pending.state).toBe('reference-update');
|
|
expect(pending.label).toContain('Synthetic profile update.');
|
|
expect(pending.label).toContain('2 scans require reference recognition');
|
|
expect(withReferenceReadiness(processing, { state: 'ready', status: referenceStatus(0) })).toEqual(processing);
|
|
});
|
|
|
|
it.each(['run', 'file', 'observation'])('names AD and Entra scans explicitly after Manage and timeslot expansion, using retained %s scope', async source => {
|
|
fixture();
|
|
const date = '2026-01-01T00:00:00Z';
|
|
const expected = ['Purple Knight for Active Directory', 'Purple Knight for Entra ID',
|
|
'Purple Knight for Active Directory and Entra ID', 'PurpleKnight'];
|
|
const tool = { id: 'purple-knight', name: 'PurpleKnight' };
|
|
const assessment = { id: 'synthetic-scope-assessment', projectId, name: 'Synthetic timeslot', createdAt: date, updatedAt: date, effectiveAt: date };
|
|
const runs = expected.map((name, index) => ({ id: `scope-${index}`, assessmentId: assessment.id, sequence: index + 1,
|
|
label: 'Untrusted title says Entra ID', importedAt: date, tool: { ...tool, name: source === 'run' ? name : tool.name } }));
|
|
const files = runs.map((run, index) => ({ id: `file-${run.id}`, scanRunId: run.id, fileName: 'untrusted-entra-ad.xlsx',
|
|
format: 'Excel', sizeBytes: 100, importedAt: date, detectedTool: { ...tool, name: source === 'file' ? expected[index] : tool.name },
|
|
detection: { level: 'high', score: 1, reasons: ['synthetic'] }, sourceArtifact: { kind: 'not-retained', reason: 'Synthetic' },
|
|
processedWith: { parserId: 'xlsx', parserVersion: '1.0.0', profileId: tool.id, profileVersion: '1.0.0', mappingVersion: '1.0.0' } }));
|
|
const properties = ['purple_knight.ad.security_posture_score', 'purple_knight.entra.security_posture_score'];
|
|
const observations = source === 'observation' ? [0, 1, 2].flatMap(index => (index === 2 ? properties : [properties[index]])
|
|
.map(property => ({ scanRunId: runs[index].id, importFileId: files[index].id, tool, property, originalValue: 70 }))) : [];
|
|
// Unrelated tool and file facts must never assign Entra scope to the unknown fourth run.
|
|
observations.push({ scanRunId: runs[3].id, importFileId: files[3].id, tool: { id: 'maester', name: 'Maester' }, property: properties[1], originalValue: 70 });
|
|
observations.push({ scanRunId: runs[3].id, importFileId: 'foreign-file', tool, property: properties[1], originalValue: 70 });
|
|
api.listProjectAssessmentHistory.mockResolvedValue({ version: 1, projectId, assessments: [assessment], scanRuns: runs });
|
|
api.listProjectImportFiles.mockResolvedValue(files);
|
|
api.listProjectAssessments.mockResolvedValue([assessment]);
|
|
api.loadAssessmentModel.mockResolvedValue({ scanRuns: runs, importFiles: files, environmentObservations: observations,
|
|
findingOccurrences: runs.map(run => ({ id: `occurrence-${run.id}`, scanRunId: run.id })), findingDefinitions: [], evidence: [], diagnostics: [], reviews: [] });
|
|
const originalRuns = structuredClone(runs);
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
const timeslot = host.querySelector<HTMLDetailsElement>('.tenant-timeslot')!;
|
|
if (!timeslot.open) await act(async () => timeslot.querySelector('summary')!.click());
|
|
expect(timeslot.open).toBe(true);
|
|
expect([...timeslot.querySelectorAll('.tenant-report-copy b')].map(element => element.textContent)).toEqual(expected);
|
|
for (const name of expected) {
|
|
expect(timeslot.querySelector(`input[aria-label="Select ${name} scan report"]`)).not.toBeNull();
|
|
expect(timeslot.querySelector(`button[aria-label="Delete ${name} scan report"]`)).not.toBeNull();
|
|
}
|
|
expect(runs).toEqual(originalRuns);
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it.each(['Name', 'Consultant', 'Both'])('requires explicit confirmation of %s changes through the real client form, supports cancellation and retains unrelated data', async field => {
|
|
fixture();
|
|
const original = { id: projectId, name: 'Synthetic client', description: 'Original notes',
|
|
attributes: { consultant: 'Original consultant', retained: 'keep' }, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' };
|
|
api.listProjects.mockResolvedValue([original]);
|
|
api.saveProject.mockImplementation(async project => ({ project }));
|
|
api.getProject.mockImplementation(async () => api.saveProject.mock.calls.at(-1)![0]);
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
const setInput = async (index: number, value: string) => {
|
|
await act(async () => {
|
|
const input = host.querySelectorAll<HTMLInputElement>('#tenant-editor-form input')[index];
|
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
};
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-edit-client')!.click());
|
|
if (field !== 'Consultant') await setInput(0, 'New client');
|
|
if (field !== 'Name') await setInput(1, 'New consultant');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-save-client')!.click());
|
|
let dialog = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Confirm client information changes"]')!;
|
|
expect(dialog).not.toBeNull();
|
|
expect(api.saveProject).not.toHaveBeenCalled();
|
|
expect(dialog.textContent).toContain('Previous value'); expect(dialog.textContent).toContain('New value');
|
|
expect(dialog.querySelectorAll('tbody tr')).toHaveLength(field === 'Both' ? 2 : 1);
|
|
if (field !== 'Consultant') { expect(dialog.textContent).toContain('Synthetic client'); expect(dialog.textContent).toContain('New client'); }
|
|
if (field !== 'Name') { expect(dialog.textContent).toContain('Original consultant'); expect(dialog.textContent).toContain('New consultant'); }
|
|
expect(document.activeElement?.textContent).toBe('Cancel');
|
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
|
expect(document.querySelector('[aria-label="Confirm client information changes"]')).toBeNull();
|
|
expect(api.saveProject).not.toHaveBeenCalled();
|
|
expect(original.name).toBe('Synthetic client'); expect(original.attributes.consultant).toBe('Original consultant');
|
|
// Implicit Enter submission reaches this same native form submit path.
|
|
await act(async () => host.querySelector<HTMLFormElement>('#tenant-editor-form')!.requestSubmit());
|
|
dialog = document.querySelector<HTMLElement>('[aria-label="Confirm client information changes"]')!;
|
|
expect(dialog).not.toBeNull(); expect(api.saveProject).not.toHaveBeenCalled();
|
|
await act(async () => [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Cancel')!.click());
|
|
expect(api.saveProject).not.toHaveBeenCalled();
|
|
await act(async () => host.querySelector<HTMLFormElement>('#tenant-editor-form')!.requestSubmit());
|
|
dialog = document.querySelector<HTMLElement>('[aria-label="Confirm client information changes"]')!;
|
|
await act(async () => [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Confirm changes')!.click());
|
|
expect(api.saveProject).toHaveBeenCalledTimes(1);
|
|
expect(api.saveProject.mock.calls[0][0]).toMatchObject({ id: original.id, createdAt: original.createdAt, description: original.description,
|
|
name: field === 'Consultant' ? original.name : 'New client',
|
|
attributes: { retained: 'keep', consultant: field === 'Name' ? original.attributes.consultant : 'New consultant' } });
|
|
expect(api.getProject).toHaveBeenCalledWith(original.id);
|
|
expect(host.textContent).toContain('Saved to the database.');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('does not request identity confirmation for unchanged fields or remarks-only edits', async () => {
|
|
fixture();
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
api.saveProject.mockImplementation(async project => ({ project }));
|
|
api.getProject.mockImplementation(async () => api.saveProject.mock.calls.at(-1)![0]);
|
|
try {
|
|
await act(async () => root.render(<DataHub backendStatus="connected" onProjectSelected={() => undefined}/>));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.datahub-tenant-show')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-edit-client')!.click());
|
|
await act(async () => {
|
|
const notes = host.querySelector<HTMLTextAreaElement>('#tenant-editor-form textarea')!;
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(notes, 'Changed notes');
|
|
notes.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.tenant-save-client')!.click());
|
|
expect(document.querySelector('[aria-label="Confirm client information changes"]')).toBeNull();
|
|
expect(api.saveProject).toHaveBeenCalledTimes(1);
|
|
expect(api.saveProject.mock.calls[0][0].description).toBe('Changed notes');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|