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

GET /api/v1/repos/:owner/:name/following carries an ETag and answers a repeat with 304 until the fork, its parent or the chain moves #9189

Merged opened this

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

Merged

mason/forge:mason/9180-get-api-v1-repos-owner-name-following-ca → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9180-get-api-v1-repos-owner-name-following-ca was merged into main as cd1b6f2.

Review

Sign in to review this change.
2 files changed +180 −2
src/http/api/following.ts +16 −2
@@ -18,9 +18,23 @@ import { chainOfRepo, forgeLinkOf } from "../../spawn/chain.ts";
1818 import { upstreamPayload } from "../../spawn/upstream.ts";
1919 import { loadRepo, respondTake } from "../api.ts";
2020 import type { Handler } from "../context.ts";
21 import { holds, weakEtag } from "../etag.ts";
2122 import { apiError, body, json, notFound, requireScope, respondAccess } from "../respond.ts";
2223 import type { Router } from "../router.ts";
2324
25 /**
26 * The standing, tagged by its own bytes: a push to the fork or to the parent's
27 * default branch, or a severed chain, changes them, and a viewer who cannot see
28 * the parent is rendered a different body, so never shares a tag with one who can.
29 */
30 function tagged(request: Request, payload: unknown): Response {
31 const text = JSON.stringify(payload);
32 const etag = weakEtag(text);
33 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
34 if (holds(request, etag)) return new Response(null, { status: 304, headers });
35 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
36 }
37
2438 export function followingRoutes(router: Router<Handler>): void {
2539 // ── following. A fork's standing against its parent, read from git each
2640 // time; taking updates is git's part, converging is the crew's.
@@ -36,7 +50,7 @@ export function followingRoutes(router: Router<Handler>): void {
3650 if (chain === undefined) return apiError("not_a_fork", 404, "this repository follows nothing");
3751 const upstream = await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app));
3852 return timePhase(ctx.request, "render", () =>
39 json({ chain: chain.lines, severedAt: chain.severedAt, upstream }),
53 tagged(ctx.request, { chain: chain.lines, severedAt: chain.severedAt, upstream }),
4054 );
4155 }
4256 const converge =
@@ -47,7 +61,7 @@ export function followingRoutes(router: Router<Handler>): void {
4761 chain === undefined
4862 ? null
4963 : await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app));
50 return timePhase(ctx.request, "render", () => json({
64 return timePhase(ctx.request, "render", () => tagged(ctx.request, {
5165 chain: chain?.lines ?? null,
5266 severedAt: chain?.severedAt ?? null,
5367 upstream,
test/following-etag.test.ts added +164
@@ -0,0 +1,164 @@
1 /** GET …/following carries an ETag and answers a repeat with 304 (#9180). */
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, forkRepo, updateRepoSettings } from "../src/actions/repos.ts";
8 import type { Repo } from "../src/core/repos.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { severChain } from "../src/spawn/chain.ts";
14 import { promote } from "./helpers/members.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let alice: User;
23 let bob: User;
24 let aliceTok = "";
25 let bobTok = "";
26 let carolTok = "";
27 let upstream: Repo;
28 const FORK = "/api/v1/repos/bob/up/following";
29 const CHAIN = FORK;
30 const get = (path: string, tok: string, headers: Record<string, string> = {}) =>
31 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${tok}`, ...headers } });
32 const tagOf = async (path: string, tok: string) => {
33 const res = await get(path, tok);
34 expect(res.status).toBe(200);
35 await res.text();
36 return res.headers.get("etag")!;
37 };
38 const moved = async (path: string, tok: string, tag: string) => {
39 const res = await get(path, tok, { "if-none-match": tag });
40 expect(res.status).toBe(200);
41 const next = res.headers.get("etag")!;
42 expect(next).not.toBe(tag);
43 await res.text();
44 return next;
45 };
46 const commit = (who: User, repo: Repo, path: string) =>
47 commitToBranch(app, who, repo, { message: path, files: [{ path, content: path }] });
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-following-etag-"));
51 app = createApp({ dataDir, port: 0 });
52 servers = await serve(app);
53 const mk = async (username: string, first: boolean) => {
54 const made = await createUser(app.db, {
55 origin: "seed",
56 trusted: true,
57 username,
58 email: `${username}@forge.local`,
59 password: "a-good-passphrase",
60 adminIfFirst: first,
61 });
62 if (!made.ok) throw new Error(String(made.error));
63 return made.value;
64 };
65 alice = await mk("alice", true);
66 bob = await mk("bob", false);
67 const carol = await mk("carol", false);
68 promote(app.db, alice, bob, carol);
69 const up = await createRepo(app, alice, { name: "up", visibility: "public" });
70 if (!up.ok) throw new Error("repo");
71 upstream = up.value;
72 await commit(alice, upstream, "one.txt");
73 const fork = await forkRepo(app, bob, upstream);
74 if (!fork.ok) throw new Error(JSON.stringify(fork.error));
75 await Bun.write(
76 join(dataDir, "parent.json"),
77 JSON.stringify({
78 forgeCode: { repo: "bob/up", upstream: "root/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] },
79 }),
80 );
81 aliceTok = createToken(app.db, alice.id, "a", "ro").token;
82 bobTok = createToken(app.db, bob.id, "b", "ro").token;
83 carolTok = createToken(app.db, carol.id, "c", "ro").token;
84 });
85 afterAll(async () => {
86 await servers.stop();
87 app.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("a weak tag over the body, private no-cache, vary", async () => {
92 const res = await get(FORK, bobTok);
93 expect(res.status).toBe(200);
94 expect(res.headers.get("etag")).toMatch(/^W\/"/);
95 expect(res.headers.get("cache-control")).toBe("private, no-cache");
96 expect(res.headers.get("vary")).toContain("authorization, cookie");
97 expect(res.headers.get("content-type")).toContain("application/json");
98 expect(((await res.json()) as { parent: string }).parent).toBe("alice/up");
99 });
100
101 test("a repeat is a bodiless 304 with the same headers and the floor", async () => {
102 const tag = await tagOf(FORK, bobTok);
103 const again = await get(FORK, bobTok, { "if-none-match": tag });
104 expect(again.status).toBe(304);
105 expect(again.headers.get("etag")).toBe(tag);
106 expect(again.headers.get("cache-control")).toBe("private, no-cache");
107 expect(again.headers.get("vary")).toContain("authorization, cookie");
108 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
109 expect(again.headers.get("referrer-policy")).not.toBeNull();
110 expect(again.headers.get("content-security-policy")).not.toBeNull();
111 expect(await again.text()).toBe("");
112 });
outdated

Use a static import of getRepo instead of a dynamic import inside the test.

113
114 test("a push to the fork and a push to the parent each move the tag", async () => {
115 let tag = await tagOf(FORK, bobTok);
116 const fork = getRepo(app.db, "bob", "up")!;
117 await commit(bob, fork, "mine.txt");
118 tag = await moved(FORK, bobTok, tag);
119 expect((await get(FORK, bobTok, { "if-none-match": tag })).status).toBe(304);
120 await commit(alice, upstream, "two.txt");
121 tag = await moved(FORK, bobTok, tag);
122 expect((await get(FORK, bobTok, { "if-none-match": tag })).status).toBe(304);
123 });
124
125 test("a viewer who cannot see the parent is told a different body, so never shares its tag", async () => {
126 const seeing = await get(FORK, bobTok);
127 expect(seeing.status).toBe(200);
128 const seen = (await seeing.json()) as { parent?: string };
129 expect(seen.parent).toBe("alice/up");
130 const seeingTag = seeing.headers.get("etag")!;
131 const hidden = await updateRepoSettings(app, alice, upstream, { visibility: "private" });
outdated

This does not test box 3. Carol's 404 proves nothing about tags. Compare the etags of a viewer who sees the parent and one who does not, both on a 200.

132 expect(hidden.ok).toBe(true);
133 // The fork stays public: carol reads it, but the parent is gone from her view.
134 const blind = await get(FORK, carolTok);
135 expect(blind.status).toBe(200);
136 const blindTag = blind.headers.get("etag")!;
137 const body = (await blind.json()) as { parent?: string };
138 expect(body.parent).toBeUndefined();
139 expect(blindTag).not.toBe(seeingTag);
140 // Alice still sees the parent, and her tag is not carol's.
141 const alices = await get(FORK, aliceTok);
142 expect(alices.status).toBe(200);
143 expect(((await alices.json()) as { parent?: string }).parent).toBe("alice/up");
144 expect(alices.headers.get("etag")).not.toBe(blindTag);
145 // Carol's tag does not buy alice a 304.
146 expect((await get(FORK, aliceTok, { "if-none-match": blindTag })).status).toBe(200);
147 });
148
149 test("severing the chain moves the tag", async () => {
150 const tag = await tagOf(CHAIN, aliceTok);
151 expect((await get(CHAIN, aliceTok, { "if-none-match": tag })).status).toBe(304);
152 expect((await severChain(app)).ok).toBe(true);
153 await moved(CHAIN, aliceTok, tag);
154 });
155
156 test("not_a_fork and other errors carry no tag", async () => {
157 const plain = await get("/api/v1/repos/alice/up/following", aliceTok);
158 expect(plain.status).toBe(404);
159 expect(((await plain.json()) as { error: string }).error).toBe("not_a_fork");
160 expect(plain.headers.get("etag")).toBeNull();
161 const missing = await get("/api/v1/repos/alice/nope/following", aliceTok);
162 expect(missing.status).toBe(404);
163 expect(missing.headers.get("etag")).toBeNull();
164 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs