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

GET /api/v1/user/repos carries an ETag and answers a repeat with 304 until one of the viewer's repositories changes #9236

Merged opened this

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

Merged

mason/forge:mason/9225-get-api-v1-user-repos-carries-an-etag-an → main 2 commits fork

  • Merged. mason/9225-get-api-v1-user-repos-carries-an-etag-an was merged into main as 5475b81.

Review

Sign in to review this change.
2 files changed +167 −2
src/http/api/forge_origin.ts +24 −2
@@ -11,8 +11,9 @@
1111 * only ever seen by the token that may see it.
1212 */
1313
14 import { listReposByOwner, type Repo } from "../../core/repos.ts";
14 import { listReposByOwner, visibleReposMarker, type Repo } from "../../core/repos.ts";
1515 import type { Ctx, Handler } from "../context.ts";
16 import { holds, weakEtag } from "../etag.ts";
1617 import { json, unauthorized } from "../respond.ts";
1718 import type { Router } from "../router.ts";
1819 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -66,6 +67,9 @@ export function forgeOriginRoutes(router: Router<Handler>): void {
6667 const page = whole(ctx.url.searchParams.get("page"), 1, OWNED_MAX);
6768 const owned: Repo[] = [];
6869 let cursor: string | undefined;
70 // Pushes and open-issue counts are not in the body, so the tag also digests
71 // what moves them, batch by batch, for the viewer's own listing.
72 const markers: string[] = [];
6973 timePhase(ctx.request, "repos", () => {
7074 while (owned.length < OWNED_MAX) {
7175 const batch = listReposByOwner(ctx.app.db, me.username, me, {
@@ -73,6 +77,13 @@ export function forgeOriginRoutes(router: Router<Handler>): void {
7377 ...(cursor === undefined ? {} : { cursor }),
7478 });
7579 owned.push(...batch);
80 markers.push(
81 visibleReposMarker(ctx.app.db, me, {
82 owner: me.username,
83 limit: PAGE_MAX,
84 ...(cursor === undefined ? {} : { cursor }),
85 }),
86 );
7687 if (batch.length < PAGE_MAX) break;
7788 cursor = batch[batch.length - 1]!.id;
7889 }
@@ -88,6 +99,17 @@ export function forgeOriginRoutes(router: Router<Handler>): void {
8899 next.searchParams.set("limit", String(limit));
89100 headers.link = `<${next.toString()}>; rel="next"`;
90101 }
91 return timePhase(ctx.request, "render", () => json(rows, 200, headers));
102 return timePhase(ctx.request, "render", () => {
103 const body = JSON.stringify(rows);
104 const etag = weakEtag(`${me.id}|${page}|${limit}|${markers.join(",")}|${body}`);
105 const tagged = {
106 ...headers,
107 etag,
108 "cache-control": "private, no-cache",
109 vary: "authorization, cookie",
110 };
111 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers: tagged });
112 return json(rows, 200, tagged);
113 });
92114 });
93115 }
test/user-own-repos-etag.test.ts added +143
@@ -0,0 +1,143 @@
1 /** GET /api/v1/user/repos is tagged and answers a repeat 304 until one of the viewer's repositories changes (#9225). */
2 import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let alice: User;
18 let tokens: Record<string, string>;
19 let open: Repo;
20
21 const get = (who: string | undefined, etag?: string, query = "") =>
22 fetch(`${servers.url}/api/v1/user/repos${query}`, {
23 headers: {
24 ...(who === undefined ? {} : { authorization: `Bearer ${tokens[who]}` }),
25 ...(etag === undefined ? {} : { "if-none-match": etag }),
26 },
27 });
28 const tagOf = async (who: string, query?: string) => {
29 const res = await get(who, undefined, query);
30 await res.arrayBuffer();
31 expect(res.status).toBe(200);
32 return res.headers.get("etag")!;
33 };
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-own-repos-etag-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const mk = async (username: string, first = false) => {
40 const r = await createUser(app.db, {
41 origin: "seed", trusted: true, username, allowReserved: true,
42 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
43 });
44 if (!r.ok) throw new Error(r.error);
45 promote(app.db, r.value);
46 return r.value;
47 };
48 await mk("root", true);
49 alice = await mk("alice");
50 const bob = await mk("bob");
51 tokens = {
52 alice: createToken(app.db, alice.id, "t", "rw").token,
53 bob: createToken(app.db, bob.id, "t", "rw").token,
54 };
55 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
56 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
57 const b = await createRepo(app, bob, { name: "bobs", visibility: "private" });
58 if (!o.ok || !s.ok || !b.ok) throw new Error("repos");
59 open = o.value;
60 });
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 describe("GET /api/v1/user/repos etag", () => {
69 test("carries a weak tag, private no-cache and vary", async () => {
70 const res = await get("alice");
71 await res.arrayBuffer();
72 expect(res.status).toBe(200);
73 expect(res.headers.get("etag")).toMatch(/^W\//);
74 expect(res.headers.get("cache-control")).toBe("private, no-cache");
75 expect(res.headers.get("vary")).toContain("authorization, cookie");
76 });
77
78 test("a repeat is a 304 with the same headers, no body and the security floor", async () => {
79 const first = await get("alice");
80 await first.arrayBuffer();
81 const tag = first.headers.get("etag")!;
82 const res = await get("alice", tag);
83 expect(res.status).toBe(304);
84 expect(await res.text()).toBe("");
85 expect(res.headers.get("etag")).toBe(tag);
86 expect(res.headers.get("cache-control")).toBe("private, no-cache");
87 expect(res.headers.get("vary")).toContain("authorization, cookie");
88 expect(res.headers.get("x-total-count")).toBe(first.headers.get("x-total-count"));
89 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
90 expect(res.headers.get("content-security-policy")).not.toBeNull();
91 });
92
93 test("no session is a 401 with no tag, even with If-None-Match: *", async () => {
94 const res = await get(undefined, "*");
95 expect(res.status).toBe(401);
96 expect(res.headers.get("etag")).toBeNull();
97 });
98
99 test("page and limit change the tag", async () => {
100 const [a, b, c] = [await tagOf("alice"), await tagOf("alice", "?limit=1"), await tagOf("alice", "?limit=1&page=2")];
101 expect(new Set([a, b, c]).size).toBe(3);
102 expect((await get("alice", b, "?limit=1&page=2")).status).toBe(200);
103 });
104
105 test("two viewers never share a tag", async () => {
106 const [a, b] = [await tagOf("alice"), await tagOf("bob")];
107 expect(a).not.toBe(b);
108 expect((await get("bob", a)).status).toBe(200);
109 });
110
111 test("a push changes the tag", async () => {
112 const tag = await tagOf("alice");
113 app.bus.append({ kind: "push", repoId: open.id, actorId: alice.id, payload: {} });
114 expect((await get("alice", tag)).status).toBe(200);
115 });
116
117 test("creating, renaming and deleting a repository each change the tag", async () => {
118 let tag = await tagOf("alice");
119 const made = await createRepo(app, alice, { name: "fresh", visibility: "public" });
120 if (!made.ok) throw new Error("repo");
121 expect((await get("alice", tag)).status).toBe(200);
122 tag = await tagOf("alice");
123 app.db.run("UPDATE repo SET name = 'renamed' WHERE id = ?", [made.value.id]);
124 expect((await get("alice", tag)).status).toBe(200);
125 tag = await tagOf("alice");
126 app.db.run("DELETE FROM repo WHERE id = ?", [made.value.id]);
127 expect((await get("alice", tag)).status).toBe(200);
128 });
129
130 test("another owner's repository does not change it", async () => {
131 const tag = await tagOf("alice");
132 const bob = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username='bob'").get()!;
133 const made = await createRepo(app, { id: bob.id, username: "bob" } as User, { name: "elsewhere", visibility: "public" });
134 if (!made.ok) throw new Error("repo");
135 expect((await get("alice", tag)).status).toBe(304);
136 });
137
138 test("turning a repository private changes the tag", async () => {
139 const tag = await tagOf("alice");
140 updateRepoSettings(app.db, open, { visibility: "private" });
141 expect((await get("alice", tag)).status).toBe(200);
142 });
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs