Version banner, File locations section, final CSP (no unsafe-inline styles, frame-src none, object-src none)

The MTR viewer draws PDFs with pdf.js instead of an iframe the server's frame-ancestors refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 17:50:05 -06:00
co-authored by Claude Opus 5.5
parent da31b0a87a
commit 6e820ba0a5
17 changed files with 845 additions and 49 deletions
@@ -0,0 +1,130 @@
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useEvents } from "../auth/useEvents";
import { buildOf, onStreamReconnect, streamReconnected } from "../lib/buildWatch";
import { callsTo, mockFetch } from "../test/fetchMock";
import { UPDATED_TEXT, VersionBanner } from "./VersionBanner";
/** /api/version answers with whatever `now.build` holds at the time of the call. */
function server(first: string) {
const now = { build: first };
const fetch = mockFetch({ "/api/version": () => ({ body: { build: now.build, stack: "ci", site_tz: "America/Denver" } }) });
const versionCalls = () => callsTo(fetch, "GET", "/api/version").length;
return { now, fetch, versionCalls };
}
describe("VersionBanner", () => {
it("stays hidden while the build is the same, and shows on focus once it changes", async () => {
const s = server("sha-aaa");
const onReload = vi.fn();
render(<VersionBanner minGapMs={0} onReload={onReload} />);
await waitFor(() => expect(s.versionCalls()).toBe(1));
act(() => void window.dispatchEvent(new Event("focus")));
await waitFor(() => expect(s.versionCalls()).toBe(2));
expect(screen.queryByTestId("version-banner")).toBeNull();
s.now.build = "sha-bbb";
act(() => void window.dispatchEvent(new Event("focus")));
const banner = await screen.findByTestId("version-banner");
expect(banner).toHaveTextContent(UPDATED_TEXT);
expect(banner).toHaveTextContent("Packet Station was updated. Reload to get the new version.");
// Never reloads by itself.
expect(onReload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
expect(onReload).toHaveBeenCalledTimes(1);
});
it("checks again when the event stream reconnects", async () => {
const s = server("1");
render(<VersionBanner minGapMs={0} />);
await waitFor(() => expect(s.versionCalls()).toBe(1));
s.now.build = "2";
act(() => streamReconnected());
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
});
it("checks again on a timer", async () => {
const s = server("1");
render(<VersionBanner minGapMs={0} intervalMs={40} />);
await waitFor(() => expect(s.versionCalls()).toBe(1));
s.now.build = "2";
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
});
it("checks again when a hidden tab comes back", async () => {
const s = server("1");
render(<VersionBanner minGapMs={0} />);
await waitFor(() => expect(s.versionCalls()).toBe(1));
s.now.build = "2";
act(() => void document.dispatchEvent(new Event("visibilitychange")));
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
});
it("asks at most once per gap when focus comes in bursts", async () => {
const s = server("1");
render(<VersionBanner />);
await waitFor(() => expect(s.versionCalls()).toBe(1));
act(() => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
streamReconnected();
});
await new Promise((r) => setTimeout(r, 20));
expect(s.versionCalls()).toBe(1);
});
it("rides out a server that is down or restarting", async () => {
let answer: { status?: number; body: unknown } = { body: { build: "1" } };
const fetch = mockFetch({ "/api/version": () => answer });
render(<VersionBanner minGapMs={0} />);
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(1));
answer = { status: 502, body: { detail: "Bad gateway" } };
act(() => void window.dispatchEvent(new Event("focus")));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(2));
expect(screen.queryByTestId("version-banner")).toBeNull();
answer = { body: { build: "1" } };
act(() => void window.dispatchEvent(new Event("focus")));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(3));
expect(screen.queryByTestId("version-banner")).toBeNull();
answer = { body: { build: "2" } };
act(() => void window.dispatchEvent(new Event("focus")));
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
});
});
describe("buildOf", () => {
it("reads build, falls back to version, and ignores odd shapes", () => {
expect(buildOf({ build: " sha-1 ", version: "0.1" })).toBe("sha-1");
expect(buildOf({ version: "0.1" })).toBe("0.1");
expect(buildOf({ build: "", version: "" })).toBeNull();
expect(buildOf("1.2.3")).toBeNull();
expect(buildOf(null)).toBeNull();
});
});
describe("useEvents reconnect", () => {
it("reports a reconnect on the second open of the same stream, not the first", () => {
const sources: FakeEventSource[] = [];
class FakeEventSource {
listeners = new Map<string, () => void>();
constructor() {
sources.push(this);
}
addEventListener(type: string, fn: () => void) {
this.listeners.set(type, fn);
}
close() {}
}
vi.stubGlobal("EventSource", FakeEventSource);
const seen = vi.fn();
const off = onStreamReconnect(seen);
renderHook(() => useEvents("sess-1", { onRevoked: () => undefined }));
const es = sources.at(-1);
act(() => es?.listeners.get("open")?.());
expect(seen).not.toHaveBeenCalled();
act(() => es?.listeners.get("open")?.());
expect(seen).toHaveBeenCalledTimes(1);
off();
});
});