Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/iterations carries an ETag and answers a repeat with 304 until a fork in the family moves #9228
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9223-get-api-v1-repos-owner-name-iterations-c → main 2 commits fork
2 files
changed +146
−5
src/http/api/iterations.ts +9 −5
| @@ -8,7 +8,8 @@ import { iterationsOf, lanesOf } from "../../core/iterations.ts"; | ||
| 8 | 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 9 | 9 | import { loadRepo } from "../api.ts"; |
| 10 | 10 | import type { Handler } from "../context.ts"; |
| 11 | import { json, notFound } from "../respond.ts"; | |
| 11 | import { holds, weakEtag } from "../etag.ts"; | |
| 12 | import { notFound } from "../respond.ts"; | |
| 12 | 13 | import type { Router } from "../router.ts"; |
| 13 | 14 | |
| 14 | 15 | export function iterationRoutes(router: Router<Handler>): void { |
| @@ -17,9 +18,12 @@ export function iterationRoutes(router: Router<Handler>): void { | ||
| 17 | 18 | const repo = loadRepo(ctx); |
| 18 | 19 | if (!repo) return notFound(); |
| 19 | 20 | const forks = (await iterationsOf(ctx, repo))?.forks ?? []; |
| 20 | const res = json({ forks, lanes: lanesOf(forks) }); | |
| 21 | res.headers.set("cache-control", "private, no-cache"); | |
| 22 | res.headers.set("vary", "authorization, cookie"); | |
| 23 | return res; | |
| 21 | // The tag is of the exact bytes sent for this viewer, reached only past the access check: a fork made, | |
| 22 | // deleted, pushed or turned private changes the body, and so the tag (#9223). | |
| 23 | const text = JSON.stringify({ forks, lanes: lanesOf(forks) }); | |
| 24 | const etag = weakEtag(text); | |
| 25 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 26 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 27 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 24 | 28 | }); |
| 25 | 29 | } |
test/iterations-etag.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** GET …/iterations carries a weak ETag and answers a repeat with 304 until a fork in the family moves (#9223). */ | |
| 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 { pinBuild } from "../src/actions/builds.ts"; | |
| 7 | import { commitToBranch, createRepo, deleteRepo, forkRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { forget } from "../src/core/ttl_cache.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { weakEtag } from "../src/http/etag.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | ||
| 15 | let dataDir = ""; | |
| 16 | let app: App, servers: Servers; | |
| 17 | let alice: any, bob: any, carol: any, parent: any, bobFork: any; | |
| 18 | let bearer = ""; | |
| 19 | const URL_ = () => `${servers.url}/api/v1/repos/alice/app/iterations`; | |
| 20 | const get = (headers: Record<string, string> = {}) => fetch(URL_(), { headers }); | |
| 21 | const tagOf = async () => { | |
| 22 | const r = await get(); | |
| 23 | await r.text(); | |
| 24 | return r.headers.get("etag")!; | |
| 25 | }; | |
| 26 | async function changed(before: string, what: string): Promise<string> { | |
| 27 | // The branch lists behind the body are kept for a few seconds; a push shows once they lapse. | |
| 28 | forget(app); | |
| 29 | const res = await get({ "if-none-match": before }); | |
| 30 | expect(res.status, what).toBe(200); | |
| 31 | await res.text(); | |
| 32 | const tag = res.headers.get("etag")!; | |
| 33 | expect(tag, what).not.toBe(before); | |
| 34 | return tag; | |
| 35 | } | |
| 36 | const mk = async (username: string) => { | |
| 37 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: username === "alice" }); | |
| 38 | if (!r.ok) throw new Error("user"); | |
| 39 | promote(app.db, r.value); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | const pin = async (repo: any, who: any, label: string, oid: string, extra: { parent?: string } = {}) => { | |
| 43 | const p = await pinBuild(app, who, repo, { label, oid, ...extra }); | |
| 44 | if (!p.ok) throw new Error(JSON.stringify(p.error)); | |
| 45 | }; | |
| 46 | const commit = async (repo: any, who: any, message: string, branch = "main") => { | |
| 47 | const c = await commitToBranch(app, who, repo, { branch, message, files: [{ path: `${message}.txt`, content: `${message}\n` }] }); | |
| 48 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 49 | return c.value.commitOid as string; | |
| 50 | }; | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dataDir = await mkdtemp(join(tmpdir(), "forge-iteretag-")); | |
| 54 | app = createApp({ dataDir, port: 0 }); | |
| 55 | servers = await serve(app); | |
| 56 | alice = await mk("alice"); | |
| 57 | bob = await mk("bob"); | |
| 58 | carol = await mk("carol"); | |
| 59 | const made = await createRepo(app, alice, { name: "app", visibility: "public" }); | |
| 60 | if (!made.ok) throw new Error("repo"); | |
| 61 | parent = made.value; | |
| 62 | await pin(parent, alice, "v1", await commit(parent, alice, "one")); | |
| 63 | const f = await forkRepo(app, bob, parent); | |
| 64 | if (!f.ok) throw new Error("fork"); | |
| 65 | bobFork = f.value; | |
| 66 | await pin(bobFork, bob, "b1", await commit(bobFork, bob, "bob one"), { parent: "alice/app:v1" }); | |
| 67 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 68 | method: "POST", | |
| 69 | headers: { "content-type": "application/json" }, | |
| 70 | body: JSON.stringify({ username: "bob", password: "a-good-passphrase", name: "cli", scope: "ro" }), | |
| 71 | }); | |
| 72 | bearer = ((await minted.json()) as { token: string }).token; | |
| 73 | }); | |
| 74 | afterAll(async () => { | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | await rm(dataDir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("200 carries a weak tag over the body, private no-cache and vary; a repeat is a bodiless 304 with the floor", async () => { | |
| 81 | const res = await get(); | |
| 82 | expect(res.status).toBe(200); | |
| 83 | expect(res.headers.get("content-type")).toBe("application/json"); | |
| 84 | const text = await res.text(); | |
| 85 | const body = JSON.parse(text); | |
| 86 | expect(body.forks.length).toBeGreaterThan(1); | |
| 87 | expect(Object.keys(body)).toEqual(["forks", "lanes"]); | |
| 88 | const tag = res.headers.get("etag")!; | |
| 89 | expect(tag).toBe(weakEtag(text)); | |
| 90 | expect(res.headers.get("cache-control")).toBe("private, no-cache"); | |
| 91 | expect(res.headers.get("vary")).toContain("authorization, cookie"); | |
| 92 | const again = await get({ "if-none-match": tag }); | |
| 93 | expect(again.status).toBe(304); | |
| 94 | expect(again.headers.get("etag")).toBe(tag); | |
| 95 | expect(again.headers.get("cache-control")).toBe("private, no-cache"); | |
| 96 | expect(again.headers.get("vary")).toContain("authorization, cookie"); | |
| 97 | expect(again.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 98 | expect(again.headers.get("content-security-policy")).not.toBeNull(); | |
| 99 | expect(again.headers.get("referrer-policy")).not.toBeNull(); | |
| 100 | expect(await again.text()).toBe(""); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a fork pushed, the parent pushed, a fork made and a fork deleted each change the tag", async () => { | |
| 104 | let tag = await tagOf(); | |
| 105 | await commit(bobFork, bob, "side work", "side"); | |
| 106 | tag = await changed(tag, "fork pushed"); | |
| 107 | await commit(parent, alice, "side parent", "wip"); | |
| 108 | tag = await changed(tag, "parent pushed"); | |
| 109 | const c = await forkRepo(app, carol, parent); | |
| 110 | if (!c.ok) throw new Error("fork"); | |
| 111 | tag = await changed(tag, "fork made"); | |
| 112 | expect((await deleteRepo(app, carol, c.value)).ok).toBe(true); | |
| 113 | await changed(tag, "fork deleted"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a fork turned private moves the tag, and two viewers shown different bodies never share one", async () => { | |
| 117 | const before = await tagOf(); | |
| 118 | const r = await updateRepoSettings(app, bob, bobFork, { visibility: "private" }); | |
| 119 | expect(r.ok).toBe(true); | |
| 120 | const stranger = await changed(before, "fork turned private"); | |
| 121 | const owner = await get({ authorization: `Bearer ${bearer}` }); | |
| 122 | expect(owner.status).toBe(200); | |
| 123 | const ownerTag = owner.headers.get("etag")!; | |
| 124 | expect(ownerTag).not.toBe(stranger); | |
| 125 | expect(await owner.text()).toContain('"bob/app"'); | |
| 126 | const replay = await get({ "if-none-match": ownerTag }); | |
| 127 | expect(replay.status).toBe(200); | |
| 128 | expect(await replay.text()).not.toContain('"bob/app"'); | |
| 129 | const own = await get({ authorization: `Bearer ${bearer}`, "if-none-match": ownerTag }); | |
| 130 | expect(own.status).toBe(304); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a missing repository is a 404 with no ETag, even holding *", async () => { | |
| 134 | const res = await fetch(`${servers.url}/api/v1/repos/alice/nope/iterations`, { headers: { "if-none-match": "*" } }); | |
| 135 | expect(res.status).toBe(404); | |
| 136 | expect(res.headers.get("etag")).toBeNull(); | |
| 137 | }); |
new above
offline