mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add report component tests
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# Phase 06: US4 — Interactive Report Rendering
|
||||||
|
|
||||||
|
This phase implements the full interactive report: collapsible test result cards with colour-coded severity, hop chain flow visualisation, security appliances summary, search/filter bar, and export to HTML/JSON. By the end of this phase, users receive a complete, interactive analysis report. TDD Red-Green: write all failing component tests first, then implement each component.
|
||||||
|
|
||||||
|
## Spec Kit Context
|
||||||
|
|
||||||
|
- **Feature:** 1-web-header-analyzer
|
||||||
|
- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (FR-07, FR-08, FR-09, FR-20, FR-21, FR-25)
|
||||||
|
- **Plan:** .specify/specs/1-web-header-analyzer/plan.md
|
||||||
|
- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
|
||||||
|
- **Data Model:** .specify/specs/1-web-header-analyzer/data-model.md (TestResult, HopChainNode, SecurityAppliance, AnalysisReport)
|
||||||
|
- **User Story:** US4 — Interactive Report Rendering (Scenario 1, steps 7–8)
|
||||||
|
- **Constitution:** .specify/memory/constitution.md (TDD: P6, UX: P7)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Requires Phase 05** completed (analysis results available to render)
|
||||||
|
|
||||||
|
## Severity Colour Mapping
|
||||||
|
|
||||||
|
- **Spam:** `#ff5555` (red) — FontAwesome warning/ban icon
|
||||||
|
- **Suspicious:** `#ffb86c` (amber) — FontAwesome exclamation icon
|
||||||
|
- **Clean:** `#50fa7b` (green) — FontAwesome check icon
|
||||||
|
- **Info:** `#bd93f9` (purple) — FontAwesome info icon
|
||||||
|
- **Error:** error indicator with failure explanation (FR-25)
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportContainer
|
||||||
|
├── Summary Stats (total tests, passed, failed, severity breakdown)
|
||||||
|
├── ReportSearchBar (filter by text match)
|
||||||
|
├── ReportExport (HTML / JSON download buttons)
|
||||||
|
├── SecurityAppliancesSummary (detected security products as badges)
|
||||||
|
├── HopChainVisualisation (vertical flow diagram of mail server hops)
|
||||||
|
└── TestResultCard[] (one per test result, collapsible, severity-coloured)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] T030 [US4] Write failing tests (TDD Red) in `frontend/src/__tests__/report/TestResultCard.test.tsx` (each severity level, expand/collapse, error indicator), `HopChainVisualisation.test.tsx` (render with sample hops), `ReportSearchBar.test.tsx` (filter simulation), `ReportExport.test.tsx` (export trigger), `SecurityAppliancesSummary.test.tsx` (render with sample appliances, empty state), `ReportContainer.test.tsx` (full report with mixed results)
|
||||||
|
- [ ] T031 [P] [US4] Create `frontend/src/components/report/TestResultCard.tsx` — collapsible card per test result. Severity-coloured indicator (red=spam, amber=suspicious, green=clean per FR-09), header name, monospace value, analysis text. Failed tests show error indicator (FR-25). Expand/collapse with animation, keyboard accessible (NFR-02). Verify `TestResultCard.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T032 [P] [US4] Create `frontend/src/components/report/HopChainVisualisation.tsx` — vertical flow diagram of mail server hop chain (FR-08): hostname, IP, timestamp, server version, connecting arrows. FontAwesome server/network icons. Responsive. Verify `HopChainVisualisation.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T033 [P] [US4] Create `frontend/src/components/report/ReportSearchBar.tsx` — search/filter bar above report (FR-20). Filters by text match against test name, header name, or analysis text. Highlights matches, shows count. FontAwesome search icon, Escape to clear. Verify `ReportSearchBar.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T034 [P] [US4] Create `frontend/src/components/report/ReportExport.tsx` — export as HTML (styled standalone page) or JSON (raw data) per FR-21. FontAwesome download icons, triggers browser download. Verify `ReportExport.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T035 [US4] Create `frontend/src/components/report/SecurityAppliancesSummary.tsx` — summary listing detected email security products as badges/tags with FontAwesome shield icons. Handle empty state (no appliances detected). Verify `SecurityAppliancesSummary.test.tsx` passes (TDD Green)
|
||||||
|
- [ ] T036 [US4] Create `frontend/src/components/report/ReportContainer.tsx` — top-level wrapper receiving `AnalysisReport`. Renders: summary stats (total tests, passed, failed, severity breakdown), `TestResultCard` list, `HopChainVisualisation`, `SecurityAppliancesSummary`, `ReportSearchBar`, `ReportExport`. FontAwesome summary icons. Verify `ReportContainer.test.tsx` passes (TDD Green)
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
- [ ] All vitest tests pass: `npx vitest run src/__tests__/report/`
|
||||||
|
- [ ] Report renders all test results as collapsible cards with correct severity colours
|
||||||
|
- [ ] Hop chain displays as a vertical visual flow with server details and connecting arrows
|
||||||
|
- [ ] Security appliances show as badges; empty state handled gracefully
|
||||||
|
- [ ] Search filters results in real-time across test name, header name, and analysis text
|
||||||
|
- [ ] Export JSON produces a valid JSON file containing all results
|
||||||
|
- [ ] Export HTML produces a styled standalone page viewable in any browser
|
||||||
|
- [ ] All report components are keyboard accessible
|
||||||
|
- [ ] Linting passes (`npx eslint src/`, `npx prettier --check src/`)
|
||||||
|
- [ ] Run `/speckit.analyze` to verify consistency
|
||||||
93
frontend/src/__tests__/report/HopChainVisualisation.test.tsx
Normal file
93
frontend/src/__tests__/report/HopChainVisualisation.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import HopChainVisualisation from "../../components/report/HopChainVisualisation";
|
||||||
|
import type { HopChainNode } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hopChain: HopChainNode[] = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
hostname: "mail.sender.example",
|
||||||
|
ip: "192.0.2.10",
|
||||||
|
timestamp: "2026-02-17 10:00:01",
|
||||||
|
serverInfo: "Postfix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
hostname: "mx.receiver.example",
|
||||||
|
ip: "203.0.113.5",
|
||||||
|
timestamp: "2026-02-17 10:00:04",
|
||||||
|
serverInfo: "Exchange 2019",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HopChainVisualisation", () => {
|
||||||
|
it("renders hop nodes with server details", () => {
|
||||||
|
const { container } = render(<HopChainVisualisation hopChain={hopChain} />);
|
||||||
|
|
||||||
|
const root = getByTestId(container, "hop-chain-visualisation");
|
||||||
|
expect(root).toBeTruthy();
|
||||||
|
|
||||||
|
const firstHop = getByTestId(container, "hop-chain-node-0");
|
||||||
|
expect(firstHop.textContent ?? "").toContain("mail.sender.example");
|
||||||
|
expect(firstHop.textContent ?? "").toContain("192.0.2.10");
|
||||||
|
|
||||||
|
const secondHop = getByTestId(container, "hop-chain-node-1");
|
||||||
|
expect(secondHop.textContent ?? "").toContain("mx.receiver.example");
|
||||||
|
expect(secondHop.textContent ?? "").toContain("203.0.113.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders connectors between hop nodes", () => {
|
||||||
|
const { container } = render(<HopChainVisualisation hopChain={hopChain} />);
|
||||||
|
|
||||||
|
const connectors = container.querySelectorAll(
|
||||||
|
'[data-testid^="hop-chain-connector-"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(connectors.length).toBe(hopChain.length - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
frontend/src/__tests__/report/ReportContainer.test.tsx
Normal file
163
frontend/src/__tests__/report/ReportContainer.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import ReportContainer from "../../components/report/ReportContainer";
|
||||||
|
import type { AnalysisReport } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const report: AnalysisReport = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
testId: 101,
|
||||||
|
testName: "SpamAssassin Rule Hits",
|
||||||
|
headerName: "X-Spam-Flag",
|
||||||
|
headerValue: "YES",
|
||||||
|
analysis: "Flagged by local rules.",
|
||||||
|
description: "SpamAssassin rules matched.",
|
||||||
|
severity: "spam",
|
||||||
|
status: "error",
|
||||||
|
error: "Timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 202,
|
||||||
|
testName: "Mimecast Fingerprint",
|
||||||
|
headerName: "X-Mimecast-Spam-Info",
|
||||||
|
headerValue: "none",
|
||||||
|
analysis: "No fingerprint detected.",
|
||||||
|
description: "No known fingerprint found.",
|
||||||
|
severity: "clean",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 303,
|
||||||
|
testName: "Barracuda Reputation",
|
||||||
|
headerName: "X-Barracuda",
|
||||||
|
headerValue: "neutral",
|
||||||
|
analysis: "Reputation check pending.",
|
||||||
|
description: "Awaiting response.",
|
||||||
|
severity: "suspicious",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 404,
|
||||||
|
testName: "Custom Header Check",
|
||||||
|
headerName: "X-Custom",
|
||||||
|
headerValue: "ok",
|
||||||
|
analysis: "Custom info.",
|
||||||
|
description: "Informational.",
|
||||||
|
severity: "info",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hopChain: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
hostname: "mail.sender.example",
|
||||||
|
ip: "192.0.2.10",
|
||||||
|
timestamp: "2026-02-17 10:00:01",
|
||||||
|
serverInfo: "Postfix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
hostname: "mx.receiver.example",
|
||||||
|
ip: "203.0.113.5",
|
||||||
|
timestamp: "2026-02-17 10:00:04",
|
||||||
|
serverInfo: "Exchange 2019",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
securityAppliances: [
|
||||||
|
{
|
||||||
|
name: "Mimecast Email Security",
|
||||||
|
vendor: "Mimecast",
|
||||||
|
headers: ["X-Mimecast-Spam-Info"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
totalTests: 4,
|
||||||
|
passedTests: 3,
|
||||||
|
failedTests: 1,
|
||||||
|
skippedTests: 0,
|
||||||
|
elapsedMs: 4200,
|
||||||
|
timedOut: false,
|
||||||
|
incompleteTests: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportContainer", () => {
|
||||||
|
it("renders the full report with summary stats and sections", () => {
|
||||||
|
const { container } = render(<ReportContainer report={report} />);
|
||||||
|
|
||||||
|
getByTestId(container, "report-container");
|
||||||
|
|
||||||
|
const total = getByTestId(container, "report-summary-total");
|
||||||
|
const passed = getByTestId(container, "report-summary-passed");
|
||||||
|
const failed = getByTestId(container, "report-summary-failed");
|
||||||
|
const spam = getByTestId(container, "report-summary-severity-spam");
|
||||||
|
const suspicious = getByTestId(container, "report-summary-severity-suspicious");
|
||||||
|
const clean = getByTestId(container, "report-summary-severity-clean");
|
||||||
|
const info = getByTestId(container, "report-summary-severity-info");
|
||||||
|
|
||||||
|
expect(total.textContent ?? "").toContain("4");
|
||||||
|
expect(passed.textContent ?? "").toContain("3");
|
||||||
|
expect(failed.textContent ?? "").toContain("1");
|
||||||
|
expect(spam.textContent ?? "").toContain("1");
|
||||||
|
expect(suspicious.textContent ?? "").toContain("1");
|
||||||
|
expect(clean.textContent ?? "").toContain("1");
|
||||||
|
expect(info.textContent ?? "").toContain("1");
|
||||||
|
|
||||||
|
getByTestId(container, "report-search-bar");
|
||||||
|
getByTestId(container, "report-export");
|
||||||
|
getByTestId(container, "security-appliances-summary");
|
||||||
|
getByTestId(container, "hop-chain-visualisation");
|
||||||
|
|
||||||
|
report.results.forEach((result) => {
|
||||||
|
getByTestId(container, `test-result-card-${result.testId}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
147
frontend/src/__tests__/report/ReportExport.test.tsx
Normal file
147
frontend/src/__tests__/report/ReportExport.test.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import ReportExport from "../../components/report/ReportExport";
|
||||||
|
import type { AnalysisReport } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UrlStatics = {
|
||||||
|
createObjectURL?: (blob: Blob) => string;
|
||||||
|
revokeObjectURL?: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const report: AnalysisReport = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
testId: 101,
|
||||||
|
testName: "SpamAssassin Rule Hits",
|
||||||
|
headerName: "X-Spam-Flag",
|
||||||
|
headerValue: "YES",
|
||||||
|
analysis: "Flagged by local rules.",
|
||||||
|
description: "SpamAssassin rules matched during analysis.",
|
||||||
|
severity: "spam",
|
||||||
|
status: "error",
|
||||||
|
error: "Timeout",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hopChain: [],
|
||||||
|
securityAppliances: [],
|
||||||
|
metadata: {
|
||||||
|
totalTests: 1,
|
||||||
|
passedTests: 0,
|
||||||
|
failedTests: 1,
|
||||||
|
skippedTests: 0,
|
||||||
|
elapsedMs: 1200,
|
||||||
|
timedOut: false,
|
||||||
|
incompleteTests: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let createObjectUrlSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||||
|
let revokeObjectUrlSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||||
|
let clickSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||||
|
|
||||||
|
const originalUrlStatics: UrlStatics = {
|
||||||
|
createObjectURL: (URL as unknown as UrlStatics).createObjectURL,
|
||||||
|
revokeObjectURL: (URL as unknown as UrlStatics).revokeObjectURL,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const urlStatics = URL as unknown as UrlStatics;
|
||||||
|
|
||||||
|
if (!urlStatics.createObjectURL) {
|
||||||
|
urlStatics.createObjectURL = () => "blob:report";
|
||||||
|
}
|
||||||
|
if (!urlStatics.revokeObjectURL) {
|
||||||
|
urlStatics.revokeObjectURL = () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
createObjectUrlSpy = vi
|
||||||
|
.spyOn(URL, "createObjectURL")
|
||||||
|
.mockReturnValue("blob:report");
|
||||||
|
revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
clickSpy = vi
|
||||||
|
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createObjectUrlSpy?.mockRestore();
|
||||||
|
revokeObjectUrlSpy?.mockRestore();
|
||||||
|
clickSpy?.mockRestore();
|
||||||
|
|
||||||
|
const urlStatics = URL as unknown as UrlStatics;
|
||||||
|
urlStatics.createObjectURL = originalUrlStatics.createObjectURL;
|
||||||
|
urlStatics.revokeObjectURL = originalUrlStatics.revokeObjectURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportExport", () => {
|
||||||
|
it("triggers a JSON download", () => {
|
||||||
|
const { container } = render(<ReportExport report={report} />);
|
||||||
|
|
||||||
|
const jsonButton = getByTestId(container, "report-export-json");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jsonButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers an HTML download", () => {
|
||||||
|
const { container } = render(<ReportExport report={report} />);
|
||||||
|
|
||||||
|
const htmlButton = getByTestId(container, "report-export-html");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
htmlButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
frontend/src/__tests__/report/ReportSearchBar.test.tsx
Normal file
161
frontend/src/__tests__/report/ReportSearchBar.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import ReportSearchBar from "../../components/report/ReportSearchBar";
|
||||||
|
import type { TestResult } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleResults: TestResult[] = [
|
||||||
|
{
|
||||||
|
testId: 101,
|
||||||
|
testName: "SpamAssassin Rule Hits",
|
||||||
|
headerName: "X-Spam-Flag",
|
||||||
|
headerValue: "YES",
|
||||||
|
analysis: "Flagged by local rules.",
|
||||||
|
description: "SpamAssassin rules matched.",
|
||||||
|
severity: "spam",
|
||||||
|
status: "error",
|
||||||
|
error: "Timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 202,
|
||||||
|
testName: "Mimecast Fingerprint",
|
||||||
|
headerName: "X-Mimecast-Spam-Info",
|
||||||
|
headerValue: "none",
|
||||||
|
analysis: "No fingerprint detected.",
|
||||||
|
description: "No known fingerprint found.",
|
||||||
|
severity: "clean",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 303,
|
||||||
|
testName: "Barracuda Reputation",
|
||||||
|
headerName: "X-Barracuda",
|
||||||
|
headerValue: "neutral",
|
||||||
|
analysis: "Reputation check pending.",
|
||||||
|
description: "Awaiting response.",
|
||||||
|
severity: "suspicious",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterResults = (query: string, results: TestResult[]): TestResult[] => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
return results.filter((result) => {
|
||||||
|
return (
|
||||||
|
result.testName.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
result.headerName.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
result.analysis.toLowerCase().includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReportSearchHarness = () => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const filtered = filterResults(query, sampleResults);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ReportSearchBar
|
||||||
|
query={query}
|
||||||
|
matchCount={filtered.length}
|
||||||
|
totalCount={sampleResults.length}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
/>
|
||||||
|
<ul data-testid="filtered-results">
|
||||||
|
{filtered.map((result) => (
|
||||||
|
<li key={result.testId}>{result.testName}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportSearchBar", () => {
|
||||||
|
it("filters results when the search query changes", () => {
|
||||||
|
const { container } = render(<ReportSearchHarness />);
|
||||||
|
|
||||||
|
const input = getByTestId(container, "report-search-input") as HTMLInputElement;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
input.value = "mime";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredList = getByTestId(container, "filtered-results");
|
||||||
|
expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint");
|
||||||
|
expect(filteredList.textContent ?? "").not.toContain("SpamAssassin Rule Hits");
|
||||||
|
|
||||||
|
const count = getByTestId(container, "report-search-count");
|
||||||
|
expect(count.textContent ?? "").toMatch(/1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the query on Escape", () => {
|
||||||
|
const { container } = render(<ReportSearchHarness />);
|
||||||
|
|
||||||
|
const input = getByTestId(container, "report-search-input") as HTMLInputElement;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
input.value = "spam";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
const filteredList = getByTestId(container, "filtered-results");
|
||||||
|
expect(filteredList.textContent ?? "").toContain("SpamAssassin Rule Hits");
|
||||||
|
expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import SecurityAppliancesSummary from "../../components/report/SecurityAppliancesSummary";
|
||||||
|
import type { SecurityAppliance } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleAppliances: SecurityAppliance[] = [
|
||||||
|
{
|
||||||
|
name: "Mimecast Email Security",
|
||||||
|
vendor: "Mimecast",
|
||||||
|
headers: ["X-Mimecast-Spam-Info"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Proofpoint Protection",
|
||||||
|
vendor: "Proofpoint",
|
||||||
|
headers: ["X-Proofpoint-Verdict"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SecurityAppliancesSummary", () => {
|
||||||
|
it("renders detected security appliances as badges", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SecurityAppliancesSummary appliances={sampleAppliances} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = getByTestId(container, "security-appliances-summary");
|
||||||
|
expect(summary.textContent ?? "").toContain("Mimecast Email Security");
|
||||||
|
expect(summary.textContent ?? "").toContain("Proofpoint Protection");
|
||||||
|
|
||||||
|
const firstBadge = getByTestId(container, "security-appliance-0");
|
||||||
|
expect(firstBadge.textContent ?? "").toContain("Mimecast");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty state when no appliances are detected", () => {
|
||||||
|
const { container } = render(<SecurityAppliancesSummary appliances={[]} />);
|
||||||
|
|
||||||
|
const emptyState = getByTestId(container, "security-appliances-empty");
|
||||||
|
expect(emptyState.textContent ?? "").toMatch(/No security appliances detected/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
frontend/src/__tests__/report/TestResultCard.test.tsx
Normal file
133
frontend/src/__tests__/report/TestResultCard.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import TestResultCard from "../../components/report/TestResultCard";
|
||||||
|
import type { TestResult, TestSeverity } from "../../types/analysis";
|
||||||
|
|
||||||
|
type RenderResult = {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
const render = (ui: ReactElement): RenderResult => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByTestId = (container: HTMLElement, testId: string): HTMLElement => {
|
||||||
|
const element = container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Expected element ${testId} to be rendered.`);
|
||||||
|
}
|
||||||
|
return element as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildResult = (overrides: Partial<TestResult>): TestResult => ({
|
||||||
|
testId: 101,
|
||||||
|
testName: "SpamAssassin Rule Hits",
|
||||||
|
headerName: "X-Spam-Flag",
|
||||||
|
headerValue: "YES",
|
||||||
|
analysis: "Flagged by local rules.",
|
||||||
|
description: "SpamAssassin rules matched during analysis.",
|
||||||
|
severity: "spam",
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TestResultCard", () => {
|
||||||
|
it("renders severity labels with mapped styles", () => {
|
||||||
|
const severityCases: Array<{
|
||||||
|
severity: TestSeverity;
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
}> = [
|
||||||
|
{ severity: "spam", label: "Spam", className: "text-spam" },
|
||||||
|
{ severity: "suspicious", label: "Suspicious", className: "text-suspicious" },
|
||||||
|
{ severity: "clean", label: "Clean", className: "text-clean" },
|
||||||
|
{ severity: "info", label: "Info", className: "text-accent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
severityCases.forEach((severityCase, index) => {
|
||||||
|
const result = buildResult({
|
||||||
|
testId: 200 + index,
|
||||||
|
testName: `Severity ${severityCase.label}`,
|
||||||
|
severity: severityCase.severity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<TestResultCard result={result} />);
|
||||||
|
|
||||||
|
const severityBadge = getByTestId(
|
||||||
|
container,
|
||||||
|
`test-result-severity-${result.testId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(severityBadge.textContent ?? "").toContain(severityCase.label);
|
||||||
|
expect(severityBadge.className).toContain(severityCase.className);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands and collapses on click and keyboard", () => {
|
||||||
|
const result = buildResult({ testId: 311, testName: "Header Anomaly" });
|
||||||
|
const { container } = render(<TestResultCard result={result} />);
|
||||||
|
|
||||||
|
const toggle = getByTestId(container, `test-result-toggle-${result.testId}`);
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
toggle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
toggle.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error indicators for failed tests", () => {
|
||||||
|
const result = buildResult({
|
||||||
|
testId: 404,
|
||||||
|
status: "error",
|
||||||
|
error: "SpamAssassin database timeout.",
|
||||||
|
});
|
||||||
|
const { container } = render(<TestResultCard result={result} />);
|
||||||
|
|
||||||
|
const errorIndicator = getByTestId(container, `test-result-error-${result.testId}`);
|
||||||
|
expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user