Skip to content

Commit b56e82b

Browse files
Merge branch 'main' into fix/carousel-vertical-arrow-keys-8308
2 parents 4e4cbf6 + 5a5a4f1 commit b56e82b

33 files changed

Lines changed: 1849 additions & 73 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { SnapshotReplay, SnapshotReplayCard } from "@/components/site/snapshot-replay";
5+
import type { SnapshotReplayView } from "@/lib/snapshot-replay";
6+
7+
// (#8386) Component coverage for the audience toggle + missing/withheld footers on snapshot replay.
8+
9+
const PRIVATE_REASON = "AUTHENTICATED_ONLY_REASON_TEXT";
10+
11+
function baseView(overrides: Partial<SnapshotReplayView> = {}): SnapshotReplayView {
12+
return {
13+
status: "populated",
14+
viewer: "authenticated",
15+
snapshotId: "snap-ui-1",
16+
actionType: "comment",
17+
target: { repoFullName: "Acme/Widget", pullNumber: 1, issueNumber: null },
18+
generatedAt: "2026-06-01T12:00:00.000Z",
19+
scoringModelId: "model-1",
20+
confidence: "high",
21+
freshness: "fresh",
22+
sources: [],
23+
evidenceGaps: [],
24+
evidenceComplete: true,
25+
staleReasons: [],
26+
counterfactuals: [
27+
{
28+
repoFullName: "Acme/Widget",
29+
recommendation: "approve",
30+
alternatives: [
31+
{
32+
alternative: "request_changes",
33+
group: "verdict",
34+
publicSummary: "Public summary visible to both audiences.",
35+
reason: PRIVATE_REASON,
36+
facts: ["secret-fact"],
37+
assumptions: ["secret-assumption"],
38+
},
39+
],
40+
},
41+
],
42+
withheldPrivateFields: [],
43+
notice: "All replayed evidence is fresh and complete.",
44+
...overrides,
45+
};
46+
}
47+
48+
describe("SnapshotReplayCard audience toggle (#8386)", () => {
49+
it("defaults to the authenticated view and switches to publicSafe without leaking private reason text", () => {
50+
const authenticated = baseView({ viewer: "authenticated" });
51+
const publicSafe = baseView({
52+
viewer: "public",
53+
counterfactuals: [
54+
{
55+
repoFullName: "Acme/Widget",
56+
recommendation: "approve",
57+
alternatives: [
58+
{
59+
alternative: "request_changes",
60+
group: "verdict",
61+
publicSummary: "Public summary visible to both audiences.",
62+
reason: null,
63+
facts: [],
64+
assumptions: [],
65+
},
66+
],
67+
},
68+
],
69+
withheldPrivateFields: ["counterfactual_detail"],
70+
notice: "Public-safe notice copy.",
71+
});
72+
73+
render(<SnapshotReplayCard authenticated={authenticated} publicSafe={publicSafe} />);
74+
75+
expect(screen.getByRole("button", { name: "Authenticated" }).getAttribute("aria-pressed")).toBe(
76+
"true",
77+
);
78+
expect(screen.getByText(PRIVATE_REASON)).toBeTruthy();
79+
expect(screen.getByText("Public summary visible to both audiences.")).toBeTruthy();
80+
81+
fireEvent.click(screen.getByRole("button", { name: "Public-safe" }));
82+
83+
expect(screen.getByRole("button", { name: "Public-safe" }).getAttribute("aria-pressed")).toBe(
84+
"true",
85+
);
86+
expect(screen.queryByText(PRIVATE_REASON)).toBeNull();
87+
expect(screen.getByText("Public summary visible to both audiences.")).toBeTruthy();
88+
expect(screen.getByText(/Private detail withheld for this context/)).toBeTruthy();
89+
expect(screen.getByText(/counterfactual_detail/)).toBeTruthy();
90+
});
91+
});
92+
93+
describe("SnapshotReplay rendering (#8386)", () => {
94+
it("renders only the notice for missing status — no detail sections", () => {
95+
render(
96+
<SnapshotReplay
97+
view={baseView({
98+
status: "missing",
99+
notice: "No decision snapshot is available to replay.",
100+
counterfactuals: [],
101+
withheldPrivateFields: ["counterfactual_detail"],
102+
})}
103+
/>,
104+
);
105+
106+
const root = screen.getByTestId("snapshot-replay");
107+
expect(root.getAttribute("data-status")).toBe("missing");
108+
expect(screen.getByText("No decision snapshot is available to replay.")).toBeTruthy();
109+
expect(screen.queryByText("Action")).toBeNull();
110+
expect(screen.queryByText("Why not the alternatives")).toBeNull();
111+
expect(screen.queryByText(/Private detail withheld/)).toBeNull();
112+
});
113+
114+
it("renders the withheldPrivateFields footer only when the array is non-empty", () => {
115+
const { rerender } = render(<SnapshotReplay view={baseView({ withheldPrivateFields: [] })} />);
116+
expect(screen.queryByText(/Private detail withheld for this context/)).toBeNull();
117+
118+
rerender(
119+
<SnapshotReplay view={baseView({ withheldPrivateFields: ["counterfactual_detail"] })} />,
120+
);
121+
expect(
122+
screen.getByText(/Private detail withheld for this context: counterfactual_detail/),
123+
).toBeTruthy();
124+
});
125+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { handleAnalyticsProxy } from "./analytics-proxy";
3+
4+
// #8387: the analytics proxy is the cookieless-beacon relay to the Umami-compatible upstream. Its four
5+
// security behaviors (strict allowlist, cookie strip, cf-connecting-ip-only x-forwarded-for, set-cookie
6+
// strip) had zero coverage. These pin each one against a stubbed upstream fetch.
7+
8+
const UPSTREAM = "https://tasty.aethereal.dev";
9+
10+
type ForwardedCall = { url: string; method: string; headers: Headers };
11+
12+
/** Stub global fetch to return `response`, recording each forwarded request in a typed, inspectable list. */
13+
function stubUpstream(response: Response) {
14+
const calls: ForwardedCall[] = [];
15+
const fetchMock = vi.fn(
16+
async (url: string | URL, init?: { method?: string; headers?: HeadersInit }) => {
17+
calls.push({
18+
url: String(url),
19+
method: init?.method ?? "GET",
20+
headers: new Headers(init?.headers),
21+
});
22+
return response;
23+
},
24+
);
25+
vi.stubGlobal("fetch", fetchMock);
26+
return { fetchMock, calls };
27+
}
28+
29+
function send(init: RequestInit & { path?: string; query?: string } = {}) {
30+
const { path = "/stats/api/send", query = "", ...rest } = init;
31+
return new Request(`https://loopover.ai${path}${query}`, { method: "POST", ...rest });
32+
}
33+
34+
afterEach(() => {
35+
vi.unstubAllGlobals();
36+
});
37+
38+
describe("handleAnalyticsProxy", () => {
39+
it("forwards an allowed POST to the upstream collect endpoint, preserving the query and relaying the response", async () => {
40+
const { fetchMock, calls } = stubUpstream(
41+
new Response("ok-body", { status: 202, statusText: "Accepted" }),
42+
);
43+
44+
const response = await handleAnalyticsProxy(
45+
send({ query: "?v=2&cache=abc", body: "beacon-payload" }),
46+
);
47+
48+
expect(fetchMock).toHaveBeenCalledTimes(1);
49+
// /stats prefix stripped, path + query preserved onto the real upstream host.
50+
expect(calls[0]!.url).toBe(`${UPSTREAM}/api/send?v=2&cache=abc`);
51+
expect(calls[0]!.method).toBe("POST");
52+
// Upstream status/statusText/body are relayed back untouched.
53+
expect(response).toBeInstanceOf(Response);
54+
expect(response!.status).toBe(202);
55+
expect(response!.statusText).toBe("Accepted");
56+
expect(await response!.text()).toBe("ok-body");
57+
});
58+
59+
it("rejects a disallowed method with 405 + an allow header, without ever calling fetch (method gate)", async () => {
60+
const { fetchMock } = stubUpstream(new Response("should not be used"));
61+
62+
const response = await handleAnalyticsProxy(send({ method: "GET" }));
63+
64+
expect(response!.status).toBe(405);
65+
expect(response!.headers.get("allow")).toBe("POST");
66+
expect(fetchMock).not.toHaveBeenCalled();
67+
});
68+
69+
it("returns undefined (falls through to SSR) and never fetches for a path outside the allowlist", async () => {
70+
const { fetchMock } = stubUpstream(new Response("should not be used"));
71+
72+
// The admin/auth API lives on the same upstream origin as the collect endpoint -- must NOT be proxied.
73+
expect(await handleAnalyticsProxy(send({ path: "/stats/admin" }))).toBeUndefined();
74+
expect(await handleAnalyticsProxy(send({ path: "/stats/api/collect" }))).toBeUndefined();
75+
expect(fetchMock).not.toHaveBeenCalled();
76+
});
77+
78+
it("strips the visitor's first-party cookie before forwarding (cookieless guarantee, #597)", async () => {
79+
const { calls } = stubUpstream(new Response(null, { status: 200 }));
80+
81+
await handleAnalyticsProxy(
82+
send({ headers: { cookie: "session=secret; theme=dark", "x-keep": "yes" } }),
83+
);
84+
85+
expect(calls[0]!.headers.get("cookie")).toBeNull();
86+
// Non-stripped headers still pass through, so this isn't just dropping everything.
87+
expect(calls[0]!.headers.get("x-keep")).toBe("yes");
88+
});
89+
90+
it("re-derives x-forwarded-for from the trusted cf-connecting-ip and drops any client-supplied value (no geo spoofing)", async () => {
91+
const { calls } = stubUpstream(new Response(null, { status: 200 }));
92+
93+
await handleAnalyticsProxy(
94+
send({ headers: { "cf-connecting-ip": "203.0.113.7", "x-forwarded-for": "66.66.66.66" } }),
95+
);
96+
97+
expect(calls[0]!.headers.get("x-forwarded-for")).toBe("203.0.113.7");
98+
// The trusted-IP header itself is not leaked upstream.
99+
expect(calls[0]!.headers.get("cf-connecting-ip")).toBeNull();
100+
});
101+
102+
it("does not set x-forwarded-for when there is no cf-connecting-ip", async () => {
103+
const { calls } = stubUpstream(new Response(null, { status: 200 }));
104+
105+
await handleAnalyticsProxy(send({ headers: { "x-forwarded-for": "66.66.66.66" } }));
106+
107+
expect(calls[0]!.headers.get("x-forwarded-for")).toBeNull();
108+
});
109+
110+
it("strips set-cookie from the upstream response before relaying it to the browser", async () => {
111+
stubUpstream(
112+
new Response("ok", {
113+
status: 200,
114+
headers: { "set-cookie": "umami=1; Path=/", "x-app": "v1" },
115+
}),
116+
);
117+
118+
const response = await handleAnalyticsProxy(send());
119+
120+
expect(response!.headers.get("set-cookie")).toBeNull();
121+
expect(response!.headers.get("x-app")).toBe("v1"); // unrelated response headers are still relayed
122+
});
123+
124+
it("fails quietly with 502 when the upstream fetch throws (analytics must never take the page down)", async () => {
125+
vi.stubGlobal(
126+
"fetch",
127+
vi.fn(async () => {
128+
throw new Error("network down");
129+
}),
130+
);
131+
132+
const response = await handleAnalyticsProxy(send({ body: "beacon" }));
133+
134+
expect(response!.status).toBe(502);
135+
});
136+
});

0 commit comments

Comments
 (0)