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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9203-get-api-v1-repos-owner-name-reports-carr → main 2 commits fork
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 | ||
| 19 | 19 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 20 | 20 | import { keyedPage, keyedWindow, loadRepo, originAware, withLabels } from "../api.ts"; |
| 21 | 21 | import type { Handler } from "../context.ts"; |
| 22 | import { holds, weakEtag } from "../etag.ts"; | |
| 22 | 23 | import { clientBucket } from "../peer.ts"; |
| 23 | 24 | import { proofOf } from "../report_filer.ts"; |
| 24 | 25 | import { |
| @@ -103,8 +104,8 @@ export function reportRoutes(router: Router<Handler>): void { | ||
| 103 | 104 | waitingReports(ctx.app, ctx.auth.user, repo), |
| 104 | 105 | ); |
| 105 | 106 | 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({ | |
| 108 | 109 | ...keyedPage( |
| 109 | 110 | reportViews(ctx.app, repo, waiting.value), |
| 110 | 111 | (r) => r.id, |
| @@ -112,8 +113,22 @@ export function reportRoutes(router: Router<Handler>): void { | ||
| 112 | 113 | ), |
| 113 | 114 | // Every waiting report's filer, not only this page's. |
| 114 | 115 | 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 | }); | |
| 117 | 132 | }); |
| 118 | 133 | |
| 119 | 134 | 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 | }); |
new above
offline