Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions tests/e2e/audit-log.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { test, expect, openDemoMailbox } from "./fixtures";

test.describe("audit log", () => {
test.beforeEach(async ({ page }) => {
await openDemoMailbox(page);
await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await page.getByRole("tab", { name: "Audit log" }).click();
});

test("renders events with summary, actor, and timestamp", async ({ page }) => {
await expect(page.getByText("Session started")).toBeVisible();
await expect(page.getByText("Demo Operator")).toBeVisible();

const events = page.getByRole("article");
const count = await events.count();
expect(count).toBeGreaterThan(0);

await expect(page.getByText(/Showing \d+ of 14 events/)).toBeVisible();
});

test("filters by category", async ({ page }) => {
await page.getByRole("button", { name: "Billing", exact: true }).click();
await expect(page.getByText("Postage attached for incoming message")).toBeVisible();
await expect(page.getByText("Postage settled for msg_4f2a")).toBeVisible();
await expect(page.getByText("Session started")).not.toBeVisible();
});

test("searches by summary text", async ({ page }) => {
await page
.getByPlaceholder("Search summaries, kinds, senders, or message IDs…")
.fill("bounced");
await expect(page.getByText("Message bounced")).toBeVisible();
await expect(page.getByText("Session started")).not.toBeVisible();
});

test("searches by message ID", async ({ page }) => {
await page
.getByPlaceholder("Search summaries, kinds, senders, or message IDs…")
.fill("msg_4f2a");
await expect(page.getByText("msg_4f2a")).toBeVisible();
});

test("clears search and restores all events", async ({ page }) => {
await page
.getByPlaceholder("Search summaries, kinds, senders, or message IDs…")
.fill("bounced");
await expect(page.getByText("Message bounced")).toBeVisible();

await page.getByRole("button", { name: "Clear search" }).click();
await expect(page.getByText("Session started")).toBeVisible();
});

test("shows empty state when no events match filters", async ({ page }) => {
await page
.getByPlaceholder("Search summaries, kinds, senders, or message IDs…")
.fill("zzzznotfound");
await expect(page.getByText("No events match these filters")).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters" })).toBeVisible();
});

test("clear filters button restores all events from no-match state", async ({ page }) => {
await page
.getByPlaceholder("Search summaries, kinds, senders, or message IDs…")
.fill("zzzznotfound");
await expect(page.getByText("No events match these filters")).toBeVisible();

await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByText("Session started")).toBeVisible();
});

test("copy and export buttons are enabled when events are visible", async ({ page }) => {
await expect(page.getByRole("button", { name: "Copy diagnostics" })).toBeEnabled();
await expect(page.getByRole("button", { name: /^Export JSON/ })).toBeEnabled();
});

test("shows events count and total", async ({ page }) => {
await expect(page.getByText(/Showing \d+ of 14 events/)).toBeVisible();

await page.getByRole("button", { name: "Billing", exact: true }).click();
await expect(page.getByText(/Showing \d+ of 14 events for the current filters/)).toBeVisible();
});
});
147 changes: 146 additions & 1 deletion tests/unit/audit-log/useAuditLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
formatEventAsText,
hasActiveAuditFilter,
} from "@/features/audit-log/useAuditLog";
import type { AuditFilter } from "@/features/audit-log/types";
import { CATEGORY_FOR_KIND } from "@/features/audit-log/types";
import type { AuditEventKind, AuditFilter } from "@/features/audit-log/types";

// ---------------------------------------------------------------------------
// filterAuditEvents
// ---------------------------------------------------------------------------

describe("filterAuditEvents", () => {
it("returns all events when no filters are active (success path)", () => {
Expand Down Expand Up @@ -34,8 +39,45 @@ describe("filterAuditEvents", () => {
const filter: AuditFilter = { category: "security", search: "msg_4f2a" };
expect(filterAuditEvents(MOCK_AUDIT_EVENTS, filter)).toEqual([]);
});

it("performs case-insensitive search", () => {
const filter: AuditFilter = { category: "all", search: "SESSION" };
const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter);

expect(events.every((event) => event.kind.startsWith("session."))).toBe(true);
});

it("searches by senderDisplayName in context", () => {
const filter: AuditFilter = { category: "all", search: "Marcin Kowalski" };
const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter);

expect(events.length).toBeGreaterThan(0);
expect(
events.every((event) =>
event.context?.senderDisplayName?.toLowerCase().includes("marcin kowalski"),
),
).toBe(true);
});

it("intersects category and search filters", () => {
const filter: AuditFilter = { category: "billing", search: "msg_4f2a" };
const events = filterAuditEvents(MOCK_AUDIT_EVENTS, filter);

expect(events.length).toBeGreaterThan(0);
expect(events.every((event) => event.category === "billing")).toBe(true);
expect(events.every((event) => event.context?.messageId === "msg_4f2a")).toBe(true);
});

it("handles empty events array", () => {
const filter: AuditFilter = { category: "all", search: "" };
expect(filterAuditEvents([], filter)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// hasActiveAuditFilter
// ---------------------------------------------------------------------------

describe("hasActiveAuditFilter", () => {
it("treats the default filter as inactive", () => {
expect(hasActiveAuditFilter({ category: "all", search: "" })).toBe(false);
Expand All @@ -45,8 +87,16 @@ describe("hasActiveAuditFilter", () => {
expect(hasActiveAuditFilter({ category: "policy", search: "" })).toBe(true);
expect(hasActiveAuditFilter({ category: "all", search: "session" })).toBe(true);
});

it("treats whitespace-only search as inactive because the input is trimmed", () => {
expect(hasActiveAuditFilter({ category: "all", search: " " })).toBe(false);
});
});

// ---------------------------------------------------------------------------
// formatEventAsText
// ---------------------------------------------------------------------------

describe("formatEventAsText", () => {
it("formats a readable diagnostics line without message body content", () => {
const event = MOCK_AUDIT_EVENTS[0];
Expand All @@ -57,4 +107,99 @@ describe("formatEventAsText", () => {
expect(line).toContain(event.summary);
expect(line).not.toMatch(/body=/i);
});

it("includes context key-value pairs when context is present", () => {
const event = MOCK_AUDIT_EVENTS[2];
const line = formatEventAsText(event);

expect(line).toContain(event.ts);
expect(line).toContain(event.kind);
expect(line).toContain("policyValue=request");
});

it("does not include a context segment when context is absent", () => {
const event = MOCK_AUDIT_EVENTS[0];
const line = formatEventAsText(event);

expect(line).not.toMatch(/\| $/);
});

it("renders user actor with displayName", () => {
const event = MOCK_AUDIT_EVENTS[0];
const line = formatEventAsText(event);

expect(line).toContain("Demo Operator");
});

it("renders system actor", () => {
const event = MOCK_AUDIT_EVENTS[1];
const line = formatEventAsText(event);

expect(line).toContain("system");
});

it("renders relay actor with relayId", () => {
const event = MOCK_AUDIT_EVENTS[4];
const line = formatEventAsText(event);

expect(line).toContain("relay-us-east-1");
});

it("includes multiple context fields joined by spaces", () => {
const event = MOCK_AUDIT_EVENTS[4];
const line = formatEventAsText(event);

expect(line).toContain("messageId=msg_4f2a");
expect(line).toContain("senderDisplayName=Marcin Kowalski");
});
});

// ---------------------------------------------------------------------------
// CATEGORY_FOR_KIND mapping
// ---------------------------------------------------------------------------

describe("CATEGORY_FOR_KIND", () => {
it("maps every defined AuditEventKind to a category", () => {
const kinds: AuditEventKind[] = [
"policy.default_changed",
"policy.sender_allowed",
"policy.sender_blocked",
"policy.sender_verified",
"delivery.message_received",
"delivery.receipt_issued",
"delivery.message_bounced",
"session.started",
"session.ended",
"identity.resolved",
"identity.verification_failed",
"postage.attached",
"postage.settled",
"postage.refunded",
];

for (const kind of kinds) {
expect(CATEGORY_FOR_KIND[kind]).toBeDefined();
}
});

it("correctly classifies each kind into the expected category", () => {
expect(CATEGORY_FOR_KIND["policy.default_changed"]).toBe("policy");
expect(CATEGORY_FOR_KIND["policy.sender_allowed"]).toBe("policy");
expect(CATEGORY_FOR_KIND["policy.sender_blocked"]).toBe("policy");
expect(CATEGORY_FOR_KIND["policy.sender_verified"]).toBe("policy");
expect(CATEGORY_FOR_KIND["delivery.message_received"]).toBe("delivery");
expect(CATEGORY_FOR_KIND["delivery.receipt_issued"]).toBe("delivery");
expect(CATEGORY_FOR_KIND["delivery.message_bounced"]).toBe("delivery");
expect(CATEGORY_FOR_KIND["session.started"]).toBe("security");
expect(CATEGORY_FOR_KIND["session.ended"]).toBe("security");
expect(CATEGORY_FOR_KIND["identity.resolved"]).toBe("security");
expect(CATEGORY_FOR_KIND["identity.verification_failed"]).toBe("security");
expect(CATEGORY_FOR_KIND["postage.attached"]).toBe("billing");
expect(CATEGORY_FOR_KIND["postage.settled"]).toBe("billing");
expect(CATEGORY_FOR_KIND["postage.refunded"]).toBe("billing");
});

it("has exactly 14 entries matching the AuditEventKind union", () => {
expect(Object.keys(CATEGORY_FOR_KIND)).toHaveLength(14);
});
});
24 changes: 23 additions & 1 deletion tools/v2/team/team-security-flagging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,22 @@ team-security-flagging/
services/
security-flagging.service.mjs Core pure functions — classification, validation,
status transitions
security-flagging-execution.service.mjs DI-based execution service
guards/
security-guards.mjs Pre-validation size guards and rate-limit constants
fixtures/
security-flag-cases.json Test data: email signals, valid flags, hostile
inputs, status transition pairs
execution-contract-cases.json Execution service contract test cases
tests/
security-flagging.test.mjs 50 executable tests (node:test, zero deps)
execution-contract.test.mjs 6 execution service contract tests
security-guards.test.mjs 14 guard module tests
contract/
execution-contract.d.ts TypeScript declarations for the execution contract
docs/
THREAT_MODEL.md Threat assumptions, unsafe input catalog, mitigations
PERFORMANCE.md Performance constraints, large-dataset handling
test-plan.md Scenario table, negative checks, manual checklist
review-notes.md OSS contributor review guide
specs.md Issue categories and contributor expectations
Expand Down Expand Up @@ -61,7 +71,13 @@ Or from inside the tool folder:
node --test tests/security-flagging.test.mjs
```

Expected output: **50 tests, 0 failures**.
To run all tests:

```
node --test "tools/v2/team/team-security-flagging/tests/*.test.mjs"
```

Expected output: **70 tests, 0 failures** (50 core + 6 contract + 14 guard).

---

Expand Down Expand Up @@ -200,6 +216,9 @@ All fixture emails use `*.example`, `*.example.net`, `*.example.com`, and
or MX records.
- CRLF and null-byte injection cases are tested with inline strings in the test file
because those characters cannot appear in JSON string literals.
- The guard module performs serialization (`JSON.stringify`) to check payload size.
For very large inputs this adds O(n) on input size before any other work, which
is the intended trade-off — reject early rather than validate deeply first.
- No UI component, hook, or route exists yet. Those belong in future issues.

---
Expand All @@ -211,3 +230,6 @@ All fixture emails use `*.example`, `*.example.net`, `*.example.com`, and
- [x] No files changed outside `tools/v2/team/team-security-flagging/`.
- [x] Fixtures contain no real personal data, credentials, or wallet addresses.
- [x] The tool is reviewable as a self-contained mini-product change.
- [x] Threat assumptions and unsafe inputs are documented in `docs/THREAT_MODEL.md`.
- [x] Validation, sanitization, and guard helpers exist in `guards/security-guards.mjs`.
- [x] Performance notes for large emails and datasets are documented in `docs/PERFORMANCE.md`.
Loading
Loading