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

Merged opened this

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

Merged

mason/forge:mason/9223-get-api-v1-repos-owner-name-iterations-c → main 2 commits fork

  • Merged. mason/9223-get-api-v1-repos-owner-name-iterations-c was merged into main as c08652e.

Review

Sign in to review this change.
2 files changed +146 −5
src/http/api/iterations.ts +9 −5
@@ -8,7 +8,8 @@ import { iterationsOf, lanesOf } from "../../core/iterations.ts";
88 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
99 import { loadRepo } from "../api.ts";
1010 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";
1213 import type { Router } from "../router.ts";
1314
1415 export function iterationRoutes(router: Router<Handler>): void {
@@ -17,9 +18,12 @@ export function iterationRoutes(router: Router<Handler>): void {
1718 const repo = loadRepo(ctx);
1819 if (!repo) return notFound();
1920 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 } });
2428 });
2529 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs