Skip to content

Commit 10446d5

Browse files
benvinegarclaude
andauthored
test: cover session-broker and session-command layers (#444)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ac96ca commit 10446d5

4 files changed

Lines changed: 533 additions & 6 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Add unit coverage for the session-broker and session-command layers: brokerServer's Host/Origin DNS-rebinding validators, host:port parsing, serve-error formatting, and the session-API dispatcher, plus the CLI's daemon-availability resolution and text output. brokerServer's pure helpers are now exported for direct testing. Test-only; no user-visible change.
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createTestListedSession } from "../../test/helpers/session-daemon-fixtures";
3+
import type { HunkSessionBrokerState } from "../hunk-session/brokerAdapter";
4+
import type { SessionDaemonRequest } from "../session/protocol";
5+
import {
6+
formatDaemonServeError,
7+
handleSessionApiRequest,
8+
isAllowedHostPort,
9+
parseHostAndPort,
10+
validateHostHeader,
11+
validateOriginHeader,
12+
} from "./brokerServer";
13+
14+
const PORT = 7000;
15+
16+
/** Build a POST session-API request with a JSON body for the given daemon action. */
17+
function apiRequest(body: SessionDaemonRequest) {
18+
return new Request(`http://127.0.0.1:${PORT}/session-api`, {
19+
method: "POST",
20+
headers: { "content-type": "application/json" },
21+
body: JSON.stringify(body),
22+
});
23+
}
24+
25+
describe("formatDaemonServeError", () => {
26+
test("maps address-in-use failures to a port-conflict hint", () => {
27+
const err = formatDaemonServeError(new Error("listen EADDRINUSE"), "127.0.0.1", PORT);
28+
expect(err.message).toContain("already in use");
29+
expect(err.message).toContain(`127.0.0.1:${PORT}`);
30+
});
31+
32+
test("falls back to a generic start failure for other errors", () => {
33+
const err = formatDaemonServeError(new Error("boom"), "127.0.0.1", PORT);
34+
expect(err.message).toContain("Failed to start the session broker daemon");
35+
expect(err.message).toContain("boom");
36+
});
37+
38+
test("stringifies non-Error throwables", () => {
39+
const err = formatDaemonServeError("plain string", "127.0.0.1", PORT);
40+
expect(err.message).toContain("plain string");
41+
});
42+
});
43+
44+
describe("parseHostAndPort", () => {
45+
test("returns null for empty input", () => {
46+
expect(parseHostAndPort(" ")).toBeNull();
47+
});
48+
49+
test("parses a bare host with no port", () => {
50+
expect(parseHostAndPort("127.0.0.1")).toEqual({ host: "127.0.0.1", port: undefined });
51+
});
52+
53+
test("parses host:port", () => {
54+
expect(parseHostAndPort("127.0.0.1:7000")).toEqual({ host: "127.0.0.1", port: 7000 });
55+
});
56+
57+
test("rejects host:port with a non-numeric port", () => {
58+
expect(parseHostAndPort("127.0.0.1:abc")).toBeNull();
59+
});
60+
61+
test("parses a bracketed IPv6 literal with a port", () => {
62+
expect(parseHostAndPort("[::1]:7000")).toEqual({ host: "::1", port: 7000 });
63+
});
64+
65+
test("parses a bracketed IPv6 literal without a port", () => {
66+
expect(parseHostAndPort("[::1]")).toEqual({ host: "::1", port: undefined });
67+
});
68+
69+
test("rejects an unterminated bracket", () => {
70+
expect(parseHostAndPort("[::1")).toBeNull();
71+
});
72+
73+
test("rejects bracketed host with trailing junk that is not a port", () => {
74+
expect(parseHostAndPort("[::1]x")).toBeNull();
75+
});
76+
77+
test("rejects a bracketed host with a zero port", () => {
78+
expect(parseHostAndPort("[::1]:0")).toBeNull();
79+
});
80+
81+
test("tolerates an unbracketed IPv6 literal by dropping the port", () => {
82+
expect(parseHostAndPort("::1")).toEqual({ host: "::1", port: undefined });
83+
});
84+
});
85+
86+
describe("isAllowedHostPort", () => {
87+
test("accepts a loopback host on the expected port", () => {
88+
expect(isAllowedHostPort({ host: "127.0.0.1", port: PORT }, PORT, { allowRemote: false })).toBe(
89+
true,
90+
);
91+
});
92+
93+
test("defaults a missing port to 80 and rejects it against a non-80 broker", () => {
94+
expect(isAllowedHostPort({ host: "127.0.0.1" }, PORT, { allowRemote: false })).toBe(false);
95+
});
96+
97+
test("rejects a non-loopback host unless remote is allowed", () => {
98+
expect(isAllowedHostPort({ host: "10.0.0.5", port: PORT }, PORT, { allowRemote: false })).toBe(
99+
false,
100+
);
101+
expect(isAllowedHostPort({ host: "10.0.0.5", port: PORT }, PORT, { allowRemote: true })).toBe(
102+
true,
103+
);
104+
});
105+
});
106+
107+
describe("validateHostHeader", () => {
108+
test("rejects a request with no Host header", () => {
109+
const result = validateHostHeader(new Request(`http://127.0.0.1:${PORT}/`), PORT, false);
110+
expect(result?.status).toBe(400);
111+
});
112+
113+
test("rejects a Host that names a disallowed endpoint", () => {
114+
const request = new Request(`http://127.0.0.1:${PORT}/`, {
115+
headers: { host: "evil.com:7000" },
116+
});
117+
expect(validateHostHeader(request, PORT, false)?.status).toBe(403);
118+
});
119+
120+
test("accepts a loopback Host on the expected port", () => {
121+
const request = new Request(`http://127.0.0.1:${PORT}/`, {
122+
headers: { host: `127.0.0.1:${PORT}` },
123+
});
124+
expect(validateHostHeader(request, PORT, false)).toBeNull();
125+
});
126+
});
127+
128+
describe("validateOriginHeader", () => {
129+
test("allows requests with no Origin header", () => {
130+
expect(validateOriginHeader(new Request(`http://127.0.0.1:${PORT}/`), PORT, false)).toBeNull();
131+
});
132+
133+
test("rejects a malformed Origin value", () => {
134+
const request = new Request(`http://127.0.0.1:${PORT}/`, { headers: { origin: "not a url" } });
135+
expect(validateOriginHeader(request, PORT, false)?.status).toBe(403);
136+
});
137+
138+
test("rejects a non-http(s) Origin scheme", () => {
139+
const request = new Request(`http://127.0.0.1:${PORT}/`, {
140+
headers: { origin: "file://localhost" },
141+
});
142+
expect(validateOriginHeader(request, PORT, false)?.status).toBe(403);
143+
});
144+
145+
test("rejects a cross-origin browser request", () => {
146+
const request = new Request(`http://127.0.0.1:${PORT}/`, {
147+
headers: { origin: "http://evil.com" },
148+
});
149+
expect(validateOriginHeader(request, PORT, false)?.status).toBe(403);
150+
});
151+
152+
test("accepts a loopback Origin on the expected port", () => {
153+
const request = new Request(`http://127.0.0.1:${PORT}/`, {
154+
headers: { origin: `http://127.0.0.1:${PORT}` },
155+
});
156+
expect(validateOriginHeader(request, PORT, false)).toBeNull();
157+
});
158+
});
159+
160+
describe("handleSessionApiRequest", () => {
161+
/** Build a fake broker state recording dispatched commands and returning canned results. */
162+
function createFakeState(overrides: Partial<Record<string, unknown>> = {}) {
163+
const calls: Array<{ method: string; args: unknown[] }> = [];
164+
const record =
165+
(method: string, result: unknown) =>
166+
(...args: unknown[]) => {
167+
calls.push({ method, args });
168+
return result;
169+
};
170+
const state = {
171+
listSessions: record("listSessions", [createTestListedSession({ sessionId: "s-1" })]),
172+
getSession: record("getSession", createTestListedSession({ sessionId: "s-1" })),
173+
getSelectedContext: record("getSelectedContext", { sessionId: "s-1" }),
174+
getSessionReview: record("getSessionReview", { title: "review" }),
175+
listComments: record("listComments", []),
176+
dispatchCommand: record("dispatchCommand", { ok: true }),
177+
...overrides,
178+
} as unknown as HunkSessionBrokerState;
179+
return { state, calls };
180+
}
181+
182+
test("rejects non-POST methods", async () => {
183+
const { state } = createFakeState();
184+
const response = await handleSessionApiRequest(
185+
state,
186+
new Request(`http://127.0.0.1:${PORT}/session-api`, { method: "GET" }),
187+
);
188+
expect(response.status).toBe(405);
189+
});
190+
191+
test("requires a JSON content type", async () => {
192+
const { state } = createFakeState();
193+
const response = await handleSessionApiRequest(
194+
state,
195+
new Request(`http://127.0.0.1:${PORT}/session-api`, { method: "POST", body: "{}" }),
196+
);
197+
expect(response.status).toBe(415);
198+
});
199+
200+
test("returns 400 for an unparseable JSON body", async () => {
201+
const { state } = createFakeState();
202+
const response = await handleSessionApiRequest(
203+
state,
204+
new Request(`http://127.0.0.1:${PORT}/session-api`, {
205+
method: "POST",
206+
headers: { "content-type": "application/json" },
207+
body: "{ not json",
208+
}),
209+
);
210+
expect(response.status).toBe(400);
211+
});
212+
213+
test("routes list/get/context/review to the matching state methods", async () => {
214+
const { state, calls } = createFakeState();
215+
for (const action of ["list", "get", "context", "review"] as const) {
216+
const body = action === "list" ? { action } : { action, selector: { sessionId: "s-1" } };
217+
const response = await handleSessionApiRequest(
218+
state,
219+
apiRequest(body as SessionDaemonRequest),
220+
);
221+
expect(response.status).toBe(200);
222+
}
223+
expect(calls.map((c) => c.method)).toEqual([
224+
"listSessions",
225+
"getSession",
226+
"getSelectedContext",
227+
"getSessionReview",
228+
]);
229+
});
230+
231+
test("rejects a navigate request missing both hunk and line targets", async () => {
232+
const { state } = createFakeState();
233+
const response = await handleSessionApiRequest(
234+
state,
235+
apiRequest({ action: "navigate", selector: { sessionId: "s-1" } } as SessionDaemonRequest),
236+
);
237+
expect(response.status).toBe(400);
238+
expect(await response.json()).toMatchObject({ error: expect.stringContaining("navigate") });
239+
});
240+
241+
test("dispatches a navigate command when a hunk number is supplied", async () => {
242+
const { state, calls } = createFakeState();
243+
const response = await handleSessionApiRequest(
244+
state,
245+
apiRequest({
246+
action: "navigate",
247+
selector: { sessionId: "s-1" },
248+
hunkNumber: 2,
249+
} as SessionDaemonRequest),
250+
);
251+
expect(response.status).toBe(200);
252+
const dispatch = calls.find((c) => c.method === "dispatchCommand");
253+
expect(dispatch).toBeDefined();
254+
// hunkNumber is 1-based on the wire and converted to a 0-based hunkIndex.
255+
const dispatchInput = (dispatch!.args[0] as { input: { hunkIndex: number } }).input;
256+
expect(dispatchInput.hunkIndex).toBe(1);
257+
});
258+
259+
test("dispatches reload, comment-add, comment-rm, and comment-clear commands", async () => {
260+
const { state, calls } = createFakeState();
261+
const requests: SessionDaemonRequest[] = [
262+
{
263+
action: "reload",
264+
selector: { sessionId: "s-1" },
265+
nextInput: { kind: "show", ref: "HEAD~1", options: {} },
266+
} as SessionDaemonRequest,
267+
{
268+
action: "comment-add",
269+
selector: { sessionId: "s-1" },
270+
filePath: "a.ts",
271+
side: "new",
272+
line: 1,
273+
summary: "note",
274+
reveal: false,
275+
} as SessionDaemonRequest,
276+
{
277+
action: "comment-rm",
278+
selector: { sessionId: "s-1" },
279+
commentId: "c-1",
280+
} as SessionDaemonRequest,
281+
{ action: "comment-clear", selector: { sessionId: "s-1" } } as SessionDaemonRequest,
282+
];
283+
for (const body of requests) {
284+
const response = await handleSessionApiRequest(state, apiRequest(body));
285+
expect(response.status).toBe(200);
286+
}
287+
expect(calls.filter((c) => c.method === "dispatchCommand")).toHaveLength(4);
288+
});
289+
290+
test("serves the live comment-list path from state.listComments", async () => {
291+
const { state, calls } = createFakeState();
292+
const response = await handleSessionApiRequest(
293+
state,
294+
apiRequest({
295+
action: "comment-list",
296+
selector: { sessionId: "s-1" },
297+
} as SessionDaemonRequest),
298+
);
299+
expect(response.status).toBe(200);
300+
expect(calls.some((c) => c.method === "listComments")).toBe(true);
301+
});
302+
303+
test("serves the review-note comment-list path from the session snapshot", async () => {
304+
const { state } = createFakeState();
305+
const response = await handleSessionApiRequest(
306+
state,
307+
apiRequest({
308+
action: "comment-list",
309+
selector: { sessionId: "s-1" },
310+
type: "all",
311+
} as SessionDaemonRequest),
312+
);
313+
expect(response.status).toBe(200);
314+
expect(await response.json()).toHaveProperty("comments");
315+
});
316+
317+
test("returns 400 when a dispatched command rejects", async () => {
318+
const { state } = createFakeState({
319+
dispatchCommand: () => {
320+
throw new Error("session timed out");
321+
},
322+
});
323+
const response = await handleSessionApiRequest(
324+
state,
325+
apiRequest({
326+
action: "comment-rm",
327+
selector: { sessionId: "s-1" },
328+
commentId: "c-1",
329+
} as SessionDaemonRequest),
330+
);
331+
expect(response.status).toBe(400);
332+
expect(await response.json()).toMatchObject({ error: "session timed out" });
333+
});
334+
});

0 commit comments

Comments
 (0)