Public: anyone can read this; the crew can change it.

GET /api/v1/repos/:owner/:name/reports carries an ETag and answers a repeat with 304 until a report arrives or is answered #9230

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9203-get-api-v1-repos-owner-name-reports-carr → main 2 commits fork

  • Merged. mason/9203-get-api-v1-repos-owner-name-reports-carr was merged into main as dad3bfb.

Review

Sign in to review this change.
2 files changed +155 −4
src/http/api/reports.ts +19 −4
@@ -19,6 +19,7 @@ import { BODY_MAX, REQUEST_MAX, TITLE_MAX, byFiler } from "../../core/reports.ts
1919 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2020 import { keyedPage, keyedWindow, loadRepo, originAware, withLabels } from "../api.ts";
2121 import type { Handler } from "../context.ts";
22 import { holds, weakEtag } from "../etag.ts";
2223 import { clientBucket } from "../peer.ts";
2324 import { proofOf } from "../report_filer.ts";
2425 import {
@@ -103,8 +104,8 @@ export function reportRoutes(router: Router<Handler>): void {
103104 waitingReports(ctx.app, ctx.auth.user, repo),
104105 );
105106 if (!waiting.ok) return respondAccess(waiting.error, 403);
106 return timePhase(ctx.request, "render", () =>
107 json({
107 return timePhase(ctx.request, "render", () => {
108 const text = JSON.stringify({
108109 ...keyedPage(
109110 reportViews(ctx.app, repo, waiting.value),
110111 (r) => r.id,
@@ -112,8 +113,22 @@ export function reportRoutes(router: Router<Handler>): void {
112113 ),
113114 // Every waiting report's filer, not only this page's.
114115 byAgent: byFiler(waiting.value),
115 }),
116 );
116 });
117 // The tag is of the exact bytes sent: a report filed, answered or out
118 // of the window moves it, and each window has its own. Only a writer
119 // reaches here, so every viewer who may ask is shown the same body.
120 const etag = weakEtag(text);
121 const headers = {
122 etag,
123 "cache-control": "private, no-cache",
124 vary: "authorization, cookie",
125 };
126 if (holds(ctx.request, etag))
127 return new Response(null, { status: 304, headers });
128 return new Response(text, {
129 headers: { "content-type": "application/json", ...headers },
130 });
131 });
117132 });
118133
119134 router.add(
test/reports-etag.test.ts added +136
@@ -0,0 +1,136 @@
1 /** GET …/reports carries a weak ETag and answers a repeat with 304 until a report arrives or is answered (#9203). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let seq = 0;
19 const tok: Record<string, string> = {};
20 const url = (path: string) => `${servers.url}/api/v1/repos/ada/${path}`;
21 const get = (token: string | null, path: string, headers: Record<string, string> = {}) =>
22 fetch(url(path), { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers } });
23 const tagOf = async (path = "game/reports") => {
24 const r = await get(tok["owner"]!, path);
25 await r.text();
26 return r.headers.get("etag")!;
27 };
28 const file = async (title: string): Promise<string> => {
29 const r = await fetch(url("game/reports"), {
30 method: "POST",
31 headers: { "content-type": "application/json", "x-forwarded-for": `198.51.100.${++seq}` },
32 body: JSON.stringify({ title }),
33 });
34 expect(r.status).toBe(202);
35 return ((await r.json()) as { id: string }).id;
36 };
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-reports-etag-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 const make = async (username: string) => {
43 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
44 if (!r.ok) throw new Error(r.error);
45 return r.value;
46 };
47 const owner = await make("ada");
48 const other = await make("rita");
49 const made = await createRepo(app, owner, { name: "game", visibility: "public" });
50 if (!made.ok) throw new Error(String(made.error));
51 tok["owner"] = createToken(app.db, owner.id, "rw", "rw").token;
52 tok["ro"] = createToken(app.db, owner.id, "ro", "ro").token;
53 tok["other"] = createToken(app.db, other.id, "rw", "rw").token;
54 }, 30_000);
55
56 afterAll(async () => {
57 servers?.stop();
58 app?.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 test("200 carries a weak tag, private no-cache and vary; a repeat is a bodiless 304 with the floor headers", async () => {
63 await file("first");
64 const res = await get(tok["owner"]!, "game/reports");
65 expect(res.status).toBe(200);
66 const tag = res.headers.get("etag")!;
67 expect(tag).toMatch(/^W\//);
68 expect(res.headers.get("cache-control")).toBe("private, no-cache");
69 expect(res.headers.get("vary")).toContain("authorization, cookie");
70 expect(res.headers.get("content-type")).toBe("application/json");
71 expect(await res.text()).toContain("first");
72 const again = await get(tok["owner"]!, "game/reports", { "if-none-match": tag });
73 expect(again.status).toBe(304);
74 expect(again.headers.get("etag")).toBe(tag);
75 expect(again.headers.get("cache-control")).toBe("private, no-cache");
76 expect(again.headers.get("vary")).toContain("authorization, cookie");
77 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
78 expect(again.headers.get("content-security-policy")).not.toBeNull();
79 expect(await again.text()).toBe("");
80 });
81
82 test("a report filed, dismissed or accepted moves the tag", async () => {
83 const before = await tagOf();
84 const id = await file("second");
85 const filed = await get(tok["owner"]!, "game/reports", { "if-none-match": before });
86 expect(filed.status).toBe(200);
87 await filed.text();
88 const afterFiled = filed.headers.get("etag")!;
89 expect(afterFiled).not.toBe(before);
90
91 const d = await fetch(url(`game/reports/${id}/dismiss`), { method: "POST", headers: { authorization: `Bearer ${tok["owner"]}` } });
92 expect(d.status).toBe(200);
93 const dismissed = await get(tok["owner"]!, "game/reports", { "if-none-match": afterFiled });
94 expect(dismissed.status).toBe(200);
95 await dismissed.text();
96 expect(dismissed.headers.get("etag")).not.toBe(afterFiled);
97
98 const third = await file("third");
99 const mid = await tagOf();
100 const a = await fetch(url(`game/reports/${third}/accept`), {
101 method: "POST",
102 headers: { authorization: `Bearer ${tok["owner"]}`, "content-type": "application/json" },
103 body: "{}",
104 });
105 expect(a.status).toBe(201);
106 const accepted = await get(tok["owner"]!, "game/reports", { "if-none-match": mid });
107 expect(accepted.status).toBe(200);
108 await accepted.text();
109 });
110
111 test("a different window gets its own tag", async () => {
112 await file("fourth");
113 const whole = await tagOf();
114 const one = await tagOf("game/reports?limit=1");
115 expect(one).not.toBe(whole);
116 const res = await get(tok["owner"]!, "game/reports?limit=1", { "if-none-match": whole });
117 expect(res.status).toBe(200);
118 await res.text();
119 expect((await get(tok["owner"]!, "game/reports?limit=1", { "if-none-match": one })).status).toBe(304);
120 });
121
122 test("a token short of rw, a non-writer, a bad window and a missing repository answer as before, with no tag, even for *", async () => {
123 const star = { "if-none-match": "*" };
124 const ro = await get(tok["ro"]!, "game/reports", star);
125 expect(ro.status).toBe(403);
126 expect(ro.headers.get("etag")).toBeNull();
127 const other = await get(tok["other"]!, "game/reports", star);
128 expect(other.status).toBe(403);
129 expect(other.headers.get("etag")).toBeNull();
130 const bad = await get(tok["owner"]!, "game/reports?limit=0", star);
131 expect(bad.status).toBe(422);
132 expect(bad.headers.get("etag")).toBeNull();
133 const missing = await get(tok["owner"]!, "nope/reports", star);
134 expect(missing.status).toBe(404);
135 expect(missing.headers.get("etag")).toBeNull();
136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs