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

GET /api/v1/admin/subscriptions carries an ETag and answers a repeat with 304 until a subscription is added or removed #9234

Merged opened this

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

Merged

mason/forge:mason/9213-get-api-v1-admin-subscriptions-carries-a → main 2 commits fork

  • Merged. mason/9213-get-api-v1-admin-subscriptions-carries-a was merged into main as 75d55c9.

Review

Sign in to review this change.
2 files changed +120 −2
src/http/api/subscriptions.ts +8 −2
@@ -3,7 +3,8 @@
33 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
44 import { subscriptionRows } from "../../core/subscriptions.ts";
55 import type { Handler } from "../context.ts";
6 import { json, requireScope, unauthorized } from "../respond.ts";
6 import { holds, weakEtag } from "../etag.ts";
7 import { requireScope, unauthorized } from "../respond.ts";
78 import type { Router } from "../router.ts";
89
910 export function subscriptionRoutes(router: Router<Handler>): void {
@@ -12,6 +13,11 @@ export function subscriptionRoutes(router: Router<Handler>): void {
1213 if (!ctx.auth.user) return unauthorized();
1314 const denied = requireScope(ctx, "ro");
1415 if (denied) return denied;
15 return json({ items: subscriptionRows(ctx.app.db) });
16 // The tag is of the exact bytes sent, so a change moves it; reached only past the access checks.
17 const text = JSON.stringify({ items: subscriptionRows(ctx.app.db) });
18 const etag = weakEtag(text);
19 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
20 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
21 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
1622 });
1723 }
test/subscriptions-etag.test.ts added +112
@@ -0,0 +1,112 @@
1 /** GET /api/v1/admin/subscriptions carries a weak ETag and answers a repeat with 304 until a subscription changes (#9213). */
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 { addSubscription, deleteSubscription, setBudget, setGrants } from "../src/core/subscriptions.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { weakEtag } from "../src/http/etag.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 = "";
17 let app: App;
18 let servers: Servers;
19 let admin = "";
20 let member = "";
21
22 const get = (token: string | null, headers: Record<string, string> = {}) =>
23 fetch(`${servers.url}/api/v1/admin/subscriptions`, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers } });
24 const tagNow = async () => {
25 const r = await get(admin);
26 await r.text();
27 return r.headers.get("etag")!;
28 };
29
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-subs-etag-"));
32 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
33 servers = await serve(app);
34 const mk = async (username: string, extra: Record<string, unknown> = {}) => {
35 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...extra });
36 if (!u.ok) throw new Error(u.error);
37 return u.value;
38 };
39 const alice = await mk("alice", { adminIfFirst: true });
40 const bob = await mk("bob");
41 admin = createToken(app.db, alice.id, "a", "admin").token;
42 member = createToken(app.db, bob.id, "b", "admin").token;
43 }, 30_000);
44 afterAll(async () => {
45 await servers.stop();
46 app.close();
47 await rm(dataDir, { recursive: true, force: true });
48 });
49
50 const add = (name: string) => {
51 const r = addSubscription(app.db, dataDir, { name, token: `sk-ant-api03-${name}-secret-0123456789abcdef` });
52 if (!r.ok) throw new Error(r.error);
53 return r.value;
54 };
55
56 test("200 carries a weak tag over the body, private no-cache and vary", async () => {
57 const res = await get(admin);
58 expect(res.status).toBe(200);
59 const text = await res.text();
60 expect(JSON.parse(text).items).toBeArray();
61 expect(res.headers.get("content-type")).toBe("application/json");
62 expect(res.headers.get("etag")).toBe(weakEtag(text));
63 expect(res.headers.get("cache-control")).toBe("private, no-cache");
64 expect(res.headers.get("vary")).toContain("authorization, cookie");
65 });
66
67 test("a repeat holding the tag is a bodiless 304 with the same headers and the floor", async () => {
68 const first = await get(admin);
69 const tag = first.headers.get("etag")!;
70 await first.text();
71 const again = await get(admin, { "if-none-match": tag });
72 expect(again.status).toBe(304);
73 expect(again.headers.get("etag")).toBe(tag);
74 expect(again.headers.get("cache-control")).toBe("private, no-cache");
75 expect(again.headers.get("vary")).toContain("authorization, cookie");
76 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
77 expect(again.headers.get("content-security-policy")).not.toBeNull();
78 expect(again.headers.get("referrer-policy")).not.toBeNull();
79 expect(await again.text()).toBe("");
80 });
81
82 test("adding, retargeting and removing a subscription each move the tag", async () => {
83 const t0 = await tagNow();
84 const s = add("work");
85 const t1 = await tagNow();
86 expect(t1).not.toBe(t0);
87 const stale = await get(admin, { "if-none-match": t0 });
88 expect(stale.status).toBe(200);
89 await stale.text();
90 setGrants(app.db, "acme/site", "", [s.id]);
91 const t2 = await tagNow();
92 expect(t2).not.toBe(t1);
93 setBudget(app.db, s.id, 5);
94 const t3 = await tagNow();
95 expect(t3).not.toBe(t2);
96 expect(deleteSubscription(app.db, s.id)).toBe(true);
97 expect(await tagNow()).not.toBe(t3);
98 });
99
100 test("no session is 401 and a refused viewer 403, with no ETag even holding *", async () => {
101 const anon = await get(null, { "if-none-match": "*" });
102 expect(anon.status).toBe(401);
103 expect(anon.headers.get("etag")).toBeNull();
104 await anon.text();
105 const bob = await get(member, { "if-none-match": "*" });
106 expect(bob.status).toBe(403);
107 expect(bob.headers.get("etag")).toBeNull();
108 await bob.text();
109 const replay = await get(member, { "if-none-match": await tagNow() });
110 expect(replay.status).toBe(403);
111 expect(replay.headers.get("etag")).toBeNull();
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs