Skip to content

Commit df973ab

Browse files
committed
Extract and test some of the crazier parts of CtfPage
Signed-off-by: Jannik Hollenbach <jannik.hollenbach@owasp.org>
1 parent f08edb0 commit df973ab

File tree

6 files changed

+803
-192
lines changed

6 files changed

+803
-192
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { describe, expect, test, vi } from "vitest";
3+
4+
import type {
5+
ActivityEvent,
6+
ChallengeSolvedEvent,
7+
TeamCreatedEvent,
8+
} from "@/hooks/useActivityFeed";
9+
10+
import { useActivitySolveProcessor } from "./useActivitySolveProcessor";
11+
12+
function solveEvent(
13+
overrides: Partial<ChallengeSolvedEvent> & { challengeKey: string }
14+
): ChallengeSolvedEvent {
15+
return {
16+
eventType: "challenge_solved",
17+
team: "team-a",
18+
timestamp: "2026-02-18T10:00:00Z",
19+
challengeName: overrides.challengeKey,
20+
points: 100,
21+
isFirstSolve: true,
22+
...overrides,
23+
};
24+
}
25+
26+
function teamCreatedEvent(
27+
overrides?: Partial<TeamCreatedEvent>
28+
): TeamCreatedEvent {
29+
return {
30+
eventType: "team_created",
31+
team: "team-a",
32+
timestamp: "2026-02-18T10:00:00Z",
33+
...overrides,
34+
};
35+
}
36+
37+
describe("useActivitySolveProcessor", () => {
38+
test("does not call applySolveEvent on the initial batch of activities", () => {
39+
const applySolveEvent = vi.fn();
40+
const initialActivities: ActivityEvent[] = [
41+
solveEvent({
42+
challengeKey: "sqlInjection",
43+
timestamp: "2026-02-18T10:00:00Z",
44+
}),
45+
];
46+
47+
renderHook(() =>
48+
useActivitySolveProcessor(initialActivities, applySolveEvent)
49+
);
50+
51+
expect(applySolveEvent).not.toHaveBeenCalled();
52+
});
53+
54+
test("calls applySolveEvent for new solve events after the initial batch", () => {
55+
const applySolveEvent = vi.fn();
56+
const initialActivities: ActivityEvent[] = [
57+
solveEvent({
58+
challengeKey: "sqlInjection",
59+
timestamp: "2026-02-18T10:00:00Z",
60+
}),
61+
];
62+
63+
const { rerender } = renderHook(
64+
({ activities }) =>
65+
useActivitySolveProcessor(activities, applySolveEvent),
66+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
67+
);
68+
69+
expect(applySolveEvent).not.toHaveBeenCalled();
70+
71+
// Simulate a new activity arriving (server returns full list, newest first)
72+
const updatedActivities: ActivityEvent[] = [
73+
solveEvent({
74+
challengeKey: "xssChallenge",
75+
team: "team-b",
76+
timestamp: "2026-02-18T10:05:00Z",
77+
isFirstSolve: true,
78+
}),
79+
solveEvent({
80+
challengeKey: "sqlInjection",
81+
timestamp: "2026-02-18T10:00:00Z",
82+
}),
83+
];
84+
85+
rerender({ activities: updatedActivities });
86+
87+
expect(applySolveEvent).toHaveBeenCalledTimes(1);
88+
expect(applySolveEvent).toHaveBeenCalledWith(
89+
"xssChallenge",
90+
"team-b",
91+
true
92+
);
93+
});
94+
95+
test("does not process events with timestamps <= the last processed timestamp", () => {
96+
const applySolveEvent = vi.fn();
97+
const initialActivities: ActivityEvent[] = [
98+
solveEvent({
99+
challengeKey: "sqlInjection",
100+
timestamp: "2026-02-18T10:00:00Z",
101+
}),
102+
];
103+
104+
const { rerender } = renderHook(
105+
({ activities }) =>
106+
useActivitySolveProcessor(activities, applySolveEvent),
107+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
108+
);
109+
110+
// Re-render with the same data (e.g. long-poll returned same events)
111+
rerender({ activities: [...initialActivities] });
112+
113+
expect(applySolveEvent).not.toHaveBeenCalled();
114+
});
115+
116+
test("handles multiple new events in a single update", () => {
117+
const applySolveEvent = vi.fn();
118+
const initialActivities: ActivityEvent[] = [
119+
solveEvent({
120+
challengeKey: "sqlInjection",
121+
timestamp: "2026-02-18T10:00:00Z",
122+
}),
123+
];
124+
125+
const { rerender } = renderHook(
126+
({ activities }) =>
127+
useActivitySolveProcessor(activities, applySolveEvent),
128+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
129+
);
130+
131+
// Two new solve events arrive at once
132+
const updatedActivities: ActivityEvent[] = [
133+
solveEvent({
134+
challengeKey: "csrfChallenge",
135+
team: "team-c",
136+
timestamp: "2026-02-18T10:10:00Z",
137+
}),
138+
solveEvent({
139+
challengeKey: "xssChallenge",
140+
team: "team-b",
141+
timestamp: "2026-02-18T10:05:00Z",
142+
}),
143+
solveEvent({
144+
challengeKey: "sqlInjection",
145+
timestamp: "2026-02-18T10:00:00Z",
146+
}),
147+
];
148+
149+
rerender({ activities: updatedActivities });
150+
151+
expect(applySolveEvent).toHaveBeenCalledTimes(2);
152+
expect(applySolveEvent).toHaveBeenCalledWith(
153+
"csrfChallenge",
154+
"team-c",
155+
true
156+
);
157+
expect(applySolveEvent).toHaveBeenCalledWith(
158+
"xssChallenge",
159+
"team-b",
160+
true
161+
);
162+
});
163+
164+
test("ignores team_created events (only processes challenge_solved)", () => {
165+
const applySolveEvent = vi.fn();
166+
const initialActivities: ActivityEvent[] = [
167+
solveEvent({
168+
challengeKey: "sqlInjection",
169+
timestamp: "2026-02-18T10:00:00Z",
170+
}),
171+
];
172+
173+
const { rerender } = renderHook(
174+
({ activities }) =>
175+
useActivitySolveProcessor(activities, applySolveEvent),
176+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
177+
);
178+
179+
// New batch has a team_created event (not a solve)
180+
const updatedActivities: ActivityEvent[] = [
181+
teamCreatedEvent({
182+
team: "team-new",
183+
timestamp: "2026-02-18T10:05:00Z",
184+
}),
185+
solveEvent({
186+
challengeKey: "sqlInjection",
187+
timestamp: "2026-02-18T10:00:00Z",
188+
}),
189+
];
190+
191+
rerender({ activities: updatedActivities });
192+
193+
expect(applySolveEvent).not.toHaveBeenCalled();
194+
});
195+
196+
test("does nothing when activities is null", () => {
197+
const applySolveEvent = vi.fn();
198+
199+
renderHook(() => useActivitySolveProcessor(null, applySolveEvent));
200+
201+
expect(applySolveEvent).not.toHaveBeenCalled();
202+
});
203+
204+
test("does nothing when activities is empty", () => {
205+
const applySolveEvent = vi.fn();
206+
207+
renderHook(() => useActivitySolveProcessor([], applySolveEvent));
208+
209+
expect(applySolveEvent).not.toHaveBeenCalled();
210+
});
211+
212+
test("works when array length stays the same (capped at 15)", () => {
213+
const applySolveEvent = vi.fn();
214+
215+
// Initial batch of 3 events (simulating capped list)
216+
const initialActivities: ActivityEvent[] = [
217+
solveEvent({
218+
challengeKey: "c3",
219+
timestamp: "2026-02-18T10:03:00Z",
220+
}),
221+
solveEvent({
222+
challengeKey: "c2",
223+
timestamp: "2026-02-18T10:02:00Z",
224+
}),
225+
solveEvent({
226+
challengeKey: "c1",
227+
timestamp: "2026-02-18T10:01:00Z",
228+
}),
229+
];
230+
231+
const { rerender } = renderHook(
232+
({ activities }) =>
233+
useActivitySolveProcessor(activities, applySolveEvent),
234+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
235+
);
236+
237+
// New event arrives, oldest drops off — array length stays at 3
238+
const updatedActivities: ActivityEvent[] = [
239+
solveEvent({
240+
challengeKey: "c4",
241+
team: "team-x",
242+
timestamp: "2026-02-18T10:04:00Z",
243+
}),
244+
solveEvent({
245+
challengeKey: "c3",
246+
timestamp: "2026-02-18T10:03:00Z",
247+
}),
248+
solveEvent({
249+
challengeKey: "c2",
250+
timestamp: "2026-02-18T10:02:00Z",
251+
}),
252+
];
253+
254+
rerender({ activities: updatedActivities });
255+
256+
expect(applySolveEvent).toHaveBeenCalledTimes(1);
257+
expect(applySolveEvent).toHaveBeenCalledWith("c4", "team-x", true);
258+
});
259+
260+
test("handles successive updates correctly", () => {
261+
const applySolveEvent = vi.fn();
262+
const initialActivities: ActivityEvent[] = [
263+
solveEvent({
264+
challengeKey: "c1",
265+
timestamp: "2026-02-18T10:00:00Z",
266+
}),
267+
];
268+
269+
const { rerender } = renderHook(
270+
({ activities }) =>
271+
useActivitySolveProcessor(activities, applySolveEvent),
272+
{ initialProps: { activities: initialActivities as ActivityEvent[] } }
273+
);
274+
275+
// First update
276+
rerender({
277+
activities: [
278+
solveEvent({
279+
challengeKey: "c2",
280+
team: "team-a",
281+
timestamp: "2026-02-18T10:05:00Z",
282+
}),
283+
solveEvent({
284+
challengeKey: "c1",
285+
timestamp: "2026-02-18T10:00:00Z",
286+
}),
287+
],
288+
});
289+
290+
expect(applySolveEvent).toHaveBeenCalledTimes(1);
291+
expect(applySolveEvent).toHaveBeenCalledWith("c2", "team-a", true);
292+
293+
// Second update
294+
rerender({
295+
activities: [
296+
solveEvent({
297+
challengeKey: "c3",
298+
team: "team-b",
299+
timestamp: "2026-02-18T10:10:00Z",
300+
}),
301+
solveEvent({
302+
challengeKey: "c2",
303+
team: "team-a",
304+
timestamp: "2026-02-18T10:05:00Z",
305+
}),
306+
solveEvent({
307+
challengeKey: "c1",
308+
timestamp: "2026-02-18T10:00:00Z",
309+
}),
310+
],
311+
});
312+
313+
expect(applySolveEvent).toHaveBeenCalledTimes(2);
314+
expect(applySolveEvent).toHaveBeenCalledWith("c3", "team-b", true);
315+
});
316+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useRef } from "react";
2+
3+
import {
4+
isChallengeSolvedEvent,
5+
type ActivityEvent,
6+
} from "@/hooks/useActivityFeed";
7+
8+
/**
9+
* Watches the activity feed for new challenge-solved events and forwards them
10+
* to `applySolveEvent`.
11+
*
12+
* The first batch of activities is skipped because those events are already
13+
* reflected in the initial challenge counts from the API. Subsequent updates
14+
* are detected by comparing event timestamps (activities are sorted newest-first).
15+
*/
16+
export function useActivitySolveProcessor(
17+
activities: ActivityEvent[] | null,
18+
applySolveEvent: (
19+
challengeKey: string,
20+
solver: string,
21+
isFirstSolve: boolean
22+
) => void
23+
) {
24+
const hasSeenInitialActivitiesRef = useRef(false);
25+
const lastProcessedTimestampRef = useRef<string | null>(null);
26+
27+
useEffect(() => {
28+
if (!activities || activities.length === 0) return;
29+
30+
// Skip the first batch — those events are already reflected in the initial challenge counts
31+
if (!hasSeenInitialActivitiesRef.current) {
32+
hasSeenInitialActivitiesRef.current = true;
33+
lastProcessedTimestampRef.current = activities[0].timestamp;
34+
return;
35+
}
36+
37+
// Process only events newer than the last processed timestamp.
38+
// Activities are sorted newest-first, so we can stop as soon as we hit
39+
// an event we've already seen.
40+
for (const event of activities) {
41+
if (
42+
lastProcessedTimestampRef.current &&
43+
event.timestamp <= lastProcessedTimestampRef.current
44+
) {
45+
break;
46+
}
47+
if (isChallengeSolvedEvent(event)) {
48+
applySolveEvent(event.challengeKey, event.team, event.isFirstSolve);
49+
}
50+
}
51+
52+
lastProcessedTimestampRef.current = activities[0].timestamp;
53+
}, [activities, applySolveEvent]);
54+
}

0 commit comments

Comments
 (0)