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

GET /api/v1/repos/:owner/:name/tags carries an ETag and answers a repeat with 304 until a tag is added, moved or deleted #9163

Merged opened this

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

Merged

mason/forge:mason/9161-get-api-v1-repos-owner-name-tags-carries → main 3 commits fork

  • Merged. mason/9161-get-api-v1-repos-owner-name-tags-carries was merged into main as 14b511d.

Review

Sign in to review this change.
2 files changed +126 −1
src/http/api.ts +6 −1
@@ -1775,7 +1775,12 @@ export function apiRoutes(router: Router<Handler>): void {
17751775 const from = at + 1;
17761776 const items = all.slice(from, from + limit);
17771777 const more = from + limit < all.length;
1778 return json({ items, next_cursor: more ? items[items.length - 1]!.name : null });
1778 // The tag is of the exact bytes sent: a pushed, moved or deleted tag changes them, and so does the page.
1779 const text = JSON.stringify({ items, next_cursor: more ? items[items.length - 1]!.name : null });
1780 const etag = weakEtag(text);
1781 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
1782 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
1783 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
17791784 });
17801785 });
17811786
test/tags-etag.test.ts added +120
@@ -0,0 +1,120 @@
1 /** GET /api/v1/repos/:owner/:name/tags carries an ETag and answers a repeat with 304 (#9161). */
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 { createApp, type App } from "../src/app.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let cookie = "";
20 let dir = "";
21 const cells = process.env["FORGE_CELLS"];
22 const URL_ = "/api/v1/repos/alice/r/tags";
23
24 const git = async (...args: string[]): Promise<string> => {
25 const p = Bun.spawn(["git", "-c", "user.name=t", "-c", "[email protected]", "--git-dir", dir, ...args], { stdout: "pipe", stderr: "pipe" });
26 const out = await new Response(p.stdout).text();
27 if ((await p.exited) !== 0) throw new Error(await new Response(p.stderr).text());
28 return out.trim();
29 };
30 const get = (path: string, headers: Record<string, string> = {}) =>
31 fetch(`${servers.url}${path}`, { headers: { cookie, ...headers } });
32
33 beforeAll(async () => {
34 process.env["FORGE_CELLS"] = "off";
35 dataDir = await mkdtemp(join(tmpdir(), "forge-tags-etag-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const made = await createUser(app.db, {
39 origin: "seed",
40 trusted: true,
41 username: "alice",
42 email: "[email protected]",
43 password: "a-good-passphrase",
44 adminIfFirst: true,
45 });
46 if (!made.ok) throw new Error(String(made.error));
47 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
48 const repo = await createRepo(app, made.value, { name: "r", visibility: "public" });
49 if (!repo.ok) throw new Error(String(repo.error));
50 const done = await commitToBranch(app, made.value, repo.value, {
51 message: "change",
52 files: [{ path: "forge.json", content: "{}" }],
53 });
54 if (!done.ok) throw new Error(JSON.stringify(done.error));
55 dir = repoDir(app.reposRoot, "alice", "r");
56 const head = await git("rev-parse", repo.value.defaultBranch);
57 await git("update-ref", "refs/tags/v1", head);
58 await git("update-ref", "refs/tags/v2", head);
59 });
60
61 afterAll(async () => {
62 await servers.stop();
63 app.close();
64 if (cells === undefined) delete process.env["FORGE_CELLS"];
65 else process.env["FORGE_CELLS"] = cells;
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 test("first read has a weak etag, no-cache and vary", async () => {
70 const res = await get(URL_);
71 expect(res.status).toBe(200);
72 expect(res.headers.get("etag")).toMatch(/^W\//);
73 expect(res.headers.get("cache-control")).toBe("private, no-cache");
74 expect(res.headers.get("vary")).toContain("authorization, cookie");
75 await res.text();
76 });
77
78 test("a repeat is a 304 with no body and the floor headers", async () => {
79 const tag = (await get(URL_)).headers.get("etag")!;
80 const again = await get(URL_, { "if-none-match": tag });
81 expect(again.status).toBe(304);
82 expect(again.headers.get("etag")).toBe(tag);
83 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
84 expect(await again.text()).toBe("");
85 });
86
87 test("a different limit gets a different etag", async () => {
88 const all = (await get(URL_)).headers.get("etag");
89 const one = (await get(`${URL_}?limit=1`)).headers.get("etag");
90 expect(one).not.toBe(all);
91 });
92
93 test("errors carry no etag", async () => {
94 const bad = await get(`${URL_}?limit=0`);
95 expect(bad.status).toBe(422);
96 expect(bad.headers.get("etag")).toBeNull();
97 const stale = await get(`${URL_}?cursor=gone`);
98 expect(stale.status).toBe(422);
99 expect(stale.headers.get("etag")).toBeNull();
100 const missing = await get("/api/v1/repos/alice/nope/tags");
101 expect(missing.status).toBe(404);
102 expect(missing.headers.get("etag")).toBeNull();
103 });
104
105 test("pushing, re-pointing and deleting a tag each make the old etag miss", async () => {
106 let tag = (await get(URL_)).headers.get("etag")!;
107 const step = async (change: () => Promise<unknown>) => {
108 await change();
109 const res = await get(URL_, { "if-none-match": tag });
110 expect(res.status).toBe(200);
111 const next = res.headers.get("etag")!;
112 expect(next).not.toBe(tag);
113 tag = next;
114 };
115 await step(() => git("update-ref", "refs/tags/v3", "refs/tags/v1"));
116 await git("commit-tree", "-m", "x", "refs/tags/v1^{tree}").then((c) => git("update-ref", "refs/tags/v4", c));
117 tag = (await get(URL_)).headers.get("etag")!;
118 await step(async () => git("update-ref", "refs/tags/v3", await git("rev-parse", "refs/tags/v4")));
119 await step(() => git("update-ref", "-d", "refs/tags/v3"));
120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs