Skip to content

Commit 52300ae

Browse files
committed
Merge branch 'pr-159'
2 parents 23707d6 + de05a1d commit 52300ae

5 files changed

Lines changed: 316 additions & 26 deletions

File tree

docs/events-validation.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Event Log Validation
2+
3+
The event log treats the `/api/v1/events` response as untrusted data before it
4+
reaches React rendering.
5+
6+
`src/lib/events.ts` validates each record at the UI boundary:
7+
8+
- `id` and `type` must be strings.
9+
- `ts` must be a finite number.
10+
- `payload` must be an object.
11+
- payload previews are stringified with circular-reference protection.
12+
- oversized payload previews are truncated.
13+
- at most 200 valid events are rendered, even if the API returns more.
14+
15+
Malformed records are dropped. When the rendered list is capped, the events page
16+
shows a short note with the rendered count and total valid count.

src/app/events/Client.tsx

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22

33
import { useEffect, useState } from "react";
44
import { apiGet } from "@/lib/apiClient";
5-
6-
type AppEvent = {
7-
id: string;
8-
ts: number;
9-
type: string;
10-
payload: Record<string, unknown>;
11-
};
5+
import { parseEventsResponse, type DisplayEvent } from "@/lib/events";
126

137
export default function EventsClient() {
14-
const [items, setItems] = useState<AppEvent[] | null>(null);
8+
const [items, setItems] = useState<DisplayEvent[] | null>(null);
9+
const [totalValid, setTotalValid] = useState(0);
10+
const [capped, setCapped] = useState(false);
1511
const [error, setError] = useState<string | null>(null);
1612

1713
useEffect(() => {
18-
apiGet<{ items: AppEvent[] }>("/api/v1/events?limit=100")
19-
.then((b) => setItems(b.items))
14+
apiGet<unknown>("/api/v1/events?limit=100")
15+
.then((b) => {
16+
const parsed = parseEventsResponse(b);
17+
setItems(parsed.events);
18+
setTotalValid(parsed.totalValid);
19+
setCapped(parsed.capped);
20+
})
2021
.catch((e) => setError(e.message));
2122
}, []);
2223

@@ -34,22 +35,29 @@ export default function EventsClient() {
3435
<p className="text-sm text-neutral-600 dark:text-neutral-400">No events.</p>
3536
)}
3637
{items && items.length > 0 && (
37-
<ol className="flex flex-col gap-2">
38-
{items.map((e) => (
39-
<li
40-
key={e.id}
41-
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
42-
>
43-
<div className="flex justify-between text-neutral-500">
44-
<span>{e.type}</span>
45-
<span>{new Date(e.ts).toISOString()}</span>
46-
</div>
47-
<pre className="mt-2 whitespace-pre-wrap break-words">
48-
{JSON.stringify(e.payload, null, 2)}
49-
</pre>
50-
</li>
51-
))}
52-
</ol>
38+
<>
39+
{capped && (
40+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
41+
Showing {items.length} of {totalValid} events (capped).
42+
</p>
43+
)}
44+
<ol className="flex flex-col gap-2">
45+
{items.map((e) => (
46+
<li
47+
key={e.id}
48+
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
49+
>
50+
<div className="flex justify-between text-neutral-500">
51+
<span>{e.type}</span>
52+
<span>{new Date(e.ts).toISOString()}</span>
53+
</div>
54+
<pre className="mt-2 whitespace-pre-wrap break-words">
55+
{e.payloadPreview}
56+
</pre>
57+
</li>
58+
))}
59+
</ol>
60+
</>
5361
)}
5462
</section>
5563
</main>

src/app/events/page.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render, screen, waitFor } from "@testing-library/react";
22
import EventsPage from "./page";
3+
import { MAX_RENDERED_EVENTS } from "@/lib/events";
34

45
describe("EventsPage", () => {
56
let originalFetch: typeof global.fetch;
@@ -68,4 +69,76 @@ describe("EventsPage", () => {
6869
});
6970
expect(document.querySelectorAll("[aria-live=polite]")).toHaveLength(1);
7071
});
72+
73+
it("drops malformed event records instead of throwing during render", async () => {
74+
global.fetch = jest.fn().mockResolvedValueOnce({
75+
ok: true,
76+
text: async () =>
77+
JSON.stringify({
78+
items: [
79+
{ id: "evt1", ts: 1_782_460_000_000, type: "pair.registered", payload: {} },
80+
{ ts: 1_782_460_000_001, type: "missing.id", payload: {} },
81+
{ id: "evt2", ts: "not-a-number", type: "bad.ts", payload: {} },
82+
{ id: "evt3", ts: 1_782_460_000_002, payload: {} },
83+
{ id: "evt4", ts: 1_782_460_000_003, type: "bad.payload" },
84+
{ id: "evt5", ts: 1_782_460_000_004, type: "string.payload", payload: "nope" },
85+
],
86+
}),
87+
} as unknown as Response);
88+
89+
render(<EventsPage />);
90+
91+
expect(await screen.findByText("pair.registered")).toBeInTheDocument();
92+
expect(screen.queryByText("missing.id")).not.toBeInTheDocument();
93+
expect(screen.queryByText("bad.ts")).not.toBeInTheDocument();
94+
expect(screen.queryByText("bad.payload")).not.toBeInTheDocument();
95+
expect(screen.queryByText("string.payload")).not.toBeInTheDocument();
96+
});
97+
98+
it("bounds rendered records and surfaces a capped note", async () => {
99+
const events = Array.from({ length: MAX_RENDERED_EVENTS + 3 }, (_, index) => ({
100+
id: `evt${index}`,
101+
ts: 1_782_460_000_000 + index,
102+
type: `event.${index}`,
103+
payload: { index },
104+
}));
105+
global.fetch = jest.fn().mockResolvedValueOnce({
106+
ok: true,
107+
text: async () => JSON.stringify({ items: events }),
108+
} as unknown as Response);
109+
110+
render(<EventsPage />);
111+
112+
expect(
113+
await screen.findByText(
114+
`Showing ${MAX_RENDERED_EVENTS} of ${MAX_RENDERED_EVENTS + 3} events (capped).`,
115+
),
116+
).toBeInTheDocument();
117+
expect(screen.getByText("event.0")).toBeInTheDocument();
118+
expect(screen.getByText(`event.${MAX_RENDERED_EVENTS - 1}`)).toBeInTheDocument();
119+
expect(screen.queryByText(`event.${MAX_RENDERED_EVENTS}`)).not.toBeInTheDocument();
120+
});
121+
122+
it("truncates oversized payload previews", async () => {
123+
global.fetch = jest.fn().mockResolvedValueOnce({
124+
ok: true,
125+
text: async () =>
126+
JSON.stringify({
127+
items: [
128+
{
129+
id: "evt-large",
130+
ts: 1_782_460_000_000,
131+
type: "payload.large",
132+
payload: { body: "x".repeat(5000) },
133+
},
134+
],
135+
}),
136+
} as unknown as Response);
137+
138+
render(<EventsPage />);
139+
140+
expect(await screen.findByText("payload.large")).toBeInTheDocument();
141+
expect(screen.getByText(/truncated/)).toBeInTheDocument();
142+
});
143+
71144
});

src/lib/events.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
MAX_PAYLOAD_PREVIEW_LENGTH,
3+
MAX_RENDERED_EVENTS,
4+
parseEventsResponse,
5+
} from "./events";
6+
7+
describe("parseEventsResponse", () => {
8+
it("returns an empty safe result for malformed response shapes", () => {
9+
expect(parseEventsResponse(null)).toEqual({
10+
events: [],
11+
totalValid: 0,
12+
capped: false,
13+
});
14+
expect(parseEventsResponse({ items: "not-an-array" })).toEqual({
15+
events: [],
16+
totalValid: 0,
17+
capped: false,
18+
});
19+
});
20+
21+
it("renders circular payloads with a safe marker", () => {
22+
const circularPayload: Record<string, unknown> = { label: "root" };
23+
circularPayload.self = circularPayload;
24+
25+
const result = parseEventsResponse({
26+
items: [
27+
{
28+
id: "evt-circular",
29+
ts: 1_782_460_000_000,
30+
type: "payload.circular",
31+
payload: circularPayload,
32+
},
33+
],
34+
});
35+
36+
expect(result.events).toHaveLength(1);
37+
expect(result.events[0].payloadPreview).toContain("[Circular]");
38+
});
39+
40+
it("truncates oversized payload previews", () => {
41+
const result = parseEventsResponse({
42+
items: [
43+
{
44+
id: "evt-large",
45+
ts: 1_782_460_000_000,
46+
type: "payload.large",
47+
payload: { body: "x".repeat(MAX_PAYLOAD_PREVIEW_LENGTH + 100) },
48+
},
49+
],
50+
});
51+
52+
expect(result.events).toHaveLength(1);
53+
expect(result.events[0].payloadPreview.length).toBeLessThan(
54+
MAX_PAYLOAD_PREVIEW_LENGTH + 50,
55+
);
56+
expect(result.events[0].payloadPreview).toContain("truncated");
57+
});
58+
59+
it("caps valid event rows defensively", () => {
60+
const result = parseEventsResponse({
61+
items: Array.from({ length: MAX_RENDERED_EVENTS + 1 }, (_, index) => ({
62+
id: `evt${index}`,
63+
ts: 1_782_460_000_000 + index,
64+
type: `event.${index}`,
65+
payload: { index },
66+
})),
67+
});
68+
69+
expect(result.events).toHaveLength(MAX_RENDERED_EVENTS);
70+
expect(result.totalValid).toBe(MAX_RENDERED_EVENTS + 1);
71+
expect(result.capped).toBe(true);
72+
});
73+
});

src/lib/events.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
export type AppEvent = {
2+
id: string;
3+
ts: number;
4+
type: string;
5+
payload: unknown;
6+
};
7+
8+
export type DisplayEvent = {
9+
id: string;
10+
ts: number;
11+
type: string;
12+
payloadPreview: string;
13+
};
14+
15+
export const MAX_RENDERED_EVENTS = 200;
16+
export const MAX_PAYLOAD_PREVIEW_LENGTH = 4_000;
17+
18+
type EventsResponse = {
19+
items: unknown[];
20+
};
21+
22+
/**
23+
* Validates the event-log API response at the UI boundary and returns only
24+
* render-safe records. Malformed records are dropped instead of throwing.
25+
*/
26+
export function parseEventsResponse(raw: unknown): {
27+
events: DisplayEvent[];
28+
totalValid: number;
29+
capped: boolean;
30+
} {
31+
if (!isEventsResponse(raw)) {
32+
return { events: [], totalValid: 0, capped: false };
33+
}
34+
35+
const validEvents = raw.items.flatMap((item) => {
36+
const event = parseAppEvent(item);
37+
return event ? [event] : [];
38+
});
39+
const capped = validEvents.length > MAX_RENDERED_EVENTS;
40+
41+
return {
42+
events: validEvents.slice(0, MAX_RENDERED_EVENTS),
43+
totalValid: validEvents.length,
44+
capped,
45+
};
46+
}
47+
48+
function isEventsResponse(raw: unknown): raw is EventsResponse {
49+
return (
50+
typeof raw === "object" &&
51+
raw !== null &&
52+
Array.isArray((raw as { items?: unknown }).items)
53+
);
54+
}
55+
56+
function parseAppEvent(raw: unknown): DisplayEvent | null {
57+
if (typeof raw !== "object" || raw === null) {
58+
return null;
59+
}
60+
61+
const event = raw as Partial<AppEvent>;
62+
63+
if (
64+
typeof event.id !== "string" ||
65+
typeof event.ts !== "number" ||
66+
!Number.isFinite(event.ts) ||
67+
typeof event.type !== "string"
68+
) {
69+
return null;
70+
}
71+
72+
if (typeof event.payload !== "object" || event.payload === null) {
73+
return null;
74+
}
75+
76+
const payloadPreview = safeStringifyPayload(event.payload);
77+
78+
if (payloadPreview === null) {
79+
return null;
80+
}
81+
82+
return {
83+
id: event.id,
84+
ts: event.ts,
85+
type: event.type,
86+
payloadPreview,
87+
};
88+
}
89+
90+
function safeStringifyPayload(payload: unknown): string | null {
91+
const seen = new WeakSet<object>();
92+
93+
try {
94+
const serialized = JSON.stringify(
95+
payload,
96+
(_key, value) => {
97+
if (typeof value === "object" && value !== null) {
98+
if (seen.has(value)) {
99+
return "[Circular]";
100+
}
101+
seen.add(value);
102+
}
103+
return value;
104+
},
105+
2,
106+
);
107+
108+
if (typeof serialized !== "string") {
109+
return null;
110+
}
111+
112+
if (serialized.length <= MAX_PAYLOAD_PREVIEW_LENGTH) {
113+
return serialized;
114+
}
115+
116+
return `${serialized.slice(0, MAX_PAYLOAD_PREVIEW_LENGTH)}\n… truncated`;
117+
} catch {
118+
return null;
119+
}
120+
}

0 commit comments

Comments
 (0)