Skip to content

Commit fa8be68

Browse files
committed
feat(proof): the public per-repo proof page, the last piece of #9569
The API half already shipped: `/v1/public/repos/:owner/:repo/proof`, the badge SVG, the feature flag and the per-repo opt-out are all on main, all built on `buildProofSummary`. What was missing was the page the issue is named for. ONE IMPLEMENTATION, TWO RENDERINGS. This component renders and computes nothing. Every figure comes from the endpoint, which is the same composition the in-app trust panel (#9193) reads, so the two cannot disagree about a number. The only arithmetic is multiplying an already-computed rate by 100 for display -- a percentage derived here would be a second implementation free to drift from the first, undermining the exact property the page exists to demonstrate. The states are the substance, because each is somewhere a plausible implementation says something untrue: • BELOW THE SAMPLE FLOOR the page shows the decision COUNT and no rate -- "7 decisions, too few to claim a rate". Hiding the count along with the figure, or printing 0%, both misrepresent it. • NOT-YET-ANCHORED and EMPTY LEDGER are neutral. A new repository is not a failing one, and rendering it as an error lies in the more damaging direction. • A BROKEN ledger is the one state stated as a problem, naming the row and the kind of break, because the kind is the actionable half. • UNAVAILABLE says so explicitly: it is not a claim that anything is wrong. • AN OPTED-OUT REPO (404) is an EMPTY state, not an error -- a different ARIA role and a different meaning. Telling an opted-out repo something broke invites someone to hunt a fault that does not exist. • ACCURACY never appears without its denominator and Wilson interval. The boundary statement is rendered FROM THE PAYLOAD rather than written here, so a screenshot or an embed cannot shed the caveat while keeping the numbers. Digests are shown head-and-tail with the full value in `title`: a truncated digest with no way back to the whole cannot be checked against anything. Mutation-tested: publishing a rate below the floor, hardcoding the boundary statement, and turning the 404 into an error each fail. Closes #9569
1 parent 5ee37c4 commit fa8be68

4 files changed

Lines changed: 535 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+
import { PublicProofPage, type ProofSummary } from "./public-proof-page";
6+
7+
// #9569: the public proof page.
8+
//
9+
// What is worth testing here is not that cards render — it is the JUDGEMENT the page encodes, because every
10+
// one of these states is a place where a plausible implementation says something untrue:
11+
//
12+
// • a repo below the sample floor must show its decision COUNT and no rate. Hiding the count with the
13+
// figure, or printing 0%, both misrepresent "we have 7 decisions, too few to claim a rate".
14+
// • not-yet-anchored and empty-ledger are NEUTRAL. A page that renders a new repo as an error is lying in
15+
// the more damaging direction than one that says nothing.
16+
// • a genuinely BROKEN ledger is the one state that must read as a problem, and must name where it broke.
17+
// • an opted-out repo (404) is an empty state, not an error state — different ARIA role, different meaning.
18+
// • the boundary statement comes from the PAYLOAD. Hardcoding it here would let a screenshot or embed shed
19+
// the caveat while keeping the numbers.
20+
21+
const summary = (over: Partial<ProofSummary> = {}): ProofSummary => ({
22+
schemaVersion: 1,
23+
repoFullName: "acme/widgets",
24+
decisionCount: 128,
25+
accuracy: {
26+
state: "published",
27+
accuracy: 0.964,
28+
decided: 112,
29+
confirmed: 108,
30+
interval: { lo: 0.912, hi: 0.987 },
31+
},
32+
ledger: {
33+
state: "verified",
34+
tipSeq: 128,
35+
totalCount: 128,
36+
checkedAt: "2026-07-31T12:00:00.000Z",
37+
},
38+
anchor: {
39+
state: "anchored",
40+
backend: "rekor",
41+
seq: 128,
42+
rowHash: "a".repeat(64),
43+
at: "2026-07-30T00:00:00.000Z",
44+
},
45+
sampleRecords: [
46+
{
47+
pullNumber: 42,
48+
action: "merge",
49+
reasonCode: "gate_pass",
50+
decidedAt: "2026-07-30T10:00:00.000Z",
51+
recordDigest: "b".repeat(64),
52+
},
53+
],
54+
boundary:
55+
"This page proves what was decided and that the record is intact. It does not prove the decisions were correct.",
56+
...over,
57+
});
58+
59+
/** Renders with fetch stubbed to one response, then waits for the query to settle. */
60+
async function renderPage(response: { status: number; body?: unknown }): Promise<void> {
61+
vi.stubGlobal("fetch", async () =>
62+
response.status === 200
63+
? new Response(JSON.stringify(response.body), {
64+
status: 200,
65+
headers: { "content-type": "application/json" },
66+
})
67+
: new Response(JSON.stringify({ error: "not_found" }), {
68+
status: response.status,
69+
headers: { "content-type": "application/json" },
70+
}),
71+
);
72+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
73+
render(
74+
<QueryClientProvider client={client}>
75+
<PublicProofPage owner="acme" repo="widgets" />
76+
</QueryClientProvider>,
77+
);
78+
await waitFor(() => expect(screen.queryByText(/acme\/widgets/)).toBeTruthy());
79+
}
80+
81+
afterEach(() => {
82+
vi.unstubAllGlobals();
83+
});
84+
85+
describe("PublicProofPage (#9569)", () => {
86+
it("shows the accuracy WITH its denominator and interval, never as a bare percentage", async () => {
87+
await renderPage({ status: 200, body: summary() });
88+
await waitFor(() => expect(screen.getByText("96.4%")).toBeTruthy());
89+
// The denominator and the interval are what make the number arguable rather than promotional.
90+
expect(screen.getByText(/108 of 112 decisions confirmed/)).toBeTruthy();
91+
expect(screen.getByText(/91\.2%98\.7%/)).toBeTruthy();
92+
});
93+
94+
it("REGRESSION: below the sample floor it publishes the COUNT and no rate", async () => {
95+
await renderPage({
96+
status: 200,
97+
body: summary({ accuracy: { state: "insufficient_data", decided: 7, minimumDecisions: 20 } }),
98+
});
99+
await waitFor(() => expect(screen.getByText(/7 decisions so far/)).toBeTruthy());
100+
expect(screen.getByText(/fewer than the 20 needed/)).toBeTruthy();
101+
// The failure mode this guards: rendering a fabricated 0% for "no data".
102+
expect(screen.queryByText("0%")).toBeNull();
103+
});
104+
105+
it("renders not-yet-anchored as a NEUTRAL state, not an error", async () => {
106+
await renderPage({ status: 200, body: summary({ anchor: { state: "not_yet_anchored" } }) });
107+
await waitFor(() => expect(screen.getByText("Not yet anchored")).toBeTruthy());
108+
expect(screen.getByText(/The chain is still self-verifying/)).toBeTruthy();
109+
});
110+
111+
it("renders an empty ledger as a new repository, not a failing one", async () => {
112+
await renderPage({
113+
status: 200,
114+
body: summary({ ledger: { state: "empty", checkedAt: "2026-07-31T12:00:00.000Z" } }),
115+
});
116+
await waitFor(() => expect(screen.getByText("No decisions recorded yet")).toBeTruthy());
117+
expect(screen.getByText(/not a failing one/)).toBeTruthy();
118+
});
119+
120+
it("REGRESSION: a BROKEN ledger is stated as a problem, naming the row and the kind", async () => {
121+
// The one state that must not be softened — and the kind of break is the actionable half.
122+
await renderPage({
123+
status: 200,
124+
body: summary({
125+
ledger: {
126+
state: "broken",
127+
tipSeq: 128,
128+
totalCount: 128,
129+
checkedAt: "2026-07-31T12:00:00.000Z",
130+
brokenAtSeq: 57,
131+
brokenKind: "row_hash_mismatch",
132+
},
133+
}),
134+
});
135+
await waitFor(() => expect(screen.getByText("Ledger verification failed")).toBeTruthy());
136+
expect(screen.getByText(/sequence 57 of 128 \(row_hash_mismatch\)/)).toBeTruthy();
137+
});
138+
139+
it("says an unavailable verification is not a claim that anything is wrong", async () => {
140+
await renderPage({
141+
status: 200,
142+
body: summary({ ledger: { state: "unavailable", checkedAt: "2026-07-31T12:00:00.000Z" } }),
143+
});
144+
await waitFor(() => expect(screen.getByText("Ledger state unavailable")).toBeTruthy());
145+
expect(screen.getByText(/not a claim that anything is wrong/)).toBeTruthy();
146+
});
147+
148+
it("INVARIANT: renders the boundary statement from the payload, not from this component", async () => {
149+
// Carried in the response so an embed or screenshot cannot shed the caveat while keeping the figures.
150+
const boundary = "A bespoke boundary sentence that exists only in this fixture.";
151+
await renderPage({ status: 200, body: summary({ boundary }) });
152+
await waitFor(() => expect(screen.getByText(boundary)).toBeTruthy());
153+
});
154+
155+
it("treats an opted-out repo (404) as EMPTY, not as an error", async () => {
156+
// Different meaning and a different ARIA role: telling an opted-out repo that something broke would be
157+
// wrong, and would invite someone to go looking for a fault that does not exist.
158+
await renderPage({ status: 404 });
159+
await waitFor(() => expect(screen.getByText(/No public proof page/)).toBeTruthy());
160+
expect(screen.queryByText(/Proof summary unavailable/)).toBeNull();
161+
});
162+
163+
it("shows sample records with a digest that can be checked", async () => {
164+
await renderPage({ status: 200, body: summary() });
165+
await waitFor(() => expect(screen.getByText("#42")).toBeTruthy());
166+
expect(screen.getByText("gate_pass")).toBeTruthy();
167+
// Truncated for the eye, complete in the title — a shortened digest with no way back to the full value
168+
// cannot be checked against anything.
169+
expect(screen.getByTitle("b".repeat(64))).toBeTruthy();
170+
});
171+
172+
it("omits the sample-records card entirely when there are none", async () => {
173+
await renderPage({ status: 200, body: summary({ sampleRecords: [] }) });
174+
await waitFor(() => expect(screen.getByText("Decisions")).toBeTruthy());
175+
expect(screen.queryByText("Sample decision records")).toBeNull();
176+
});
177+
});

0 commit comments

Comments
 (0)