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

GET /api/v1/admin/drills carries an ETag and answers a repeat with 304 until a drill runs or its result changes #9233

Merged opened this

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

Merged

mason/forge:mason/9205-get-api-v1-admin-drills-carries-an-etag → main 2 commits fork

  • Merged. mason/9205-get-api-v1-admin-drills-carries-an-etag was merged into main as f217842.

Review

Sign in to review this change.
2 files changed +112 −2
src/http/api/drills.ts +9 −2
@@ -3,7 +3,8 @@
33 import { drillRows, listResilience } from "../../actions/resilience.ts";
44 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
55 import type { Handler } from "../context.ts";
6 import { apiError, json, unauthorized } from "../respond.ts";
6 import { holds, weakEtag } from "../etag.ts";
7 import { apiError, unauthorized } from "../respond.ts";
78 import type { Router } from "../router.ts";
89
910 export function drillRoutes(router: Router<Handler>): void {
@@ -12,6 +13,12 @@ export function drillRoutes(router: Router<Handler>): void {
1213 if (!ctx.auth.user) return unauthorized();
1314 const listed = listResilience(ctx.app, ctx.auth.user);
1415 if (!listed.ok) return apiError("forbidden", 403, "failure domains are for a forge admin who is a person");
15 return json({ items: drillRows(listed.value, ctx.app.config.url ?? ctx.url.origin), next_cursor: null });
16 // Tagged only past the access check: the 401 and 403 carry no ETag. The tag is of the exact bytes sent,
17 // so a drill recorded or a domain's state changing moves it.
18 const text = JSON.stringify({ items: drillRows(listed.value, ctx.app.config.url ?? ctx.url.origin), next_cursor: null });
19 const etag = weakEtag(text);
20 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
21 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
22 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
1623 });
1724 }
test/drills-etag.test.ts added +103
@@ -0,0 +1,103 @@
1 /** GET /api/v1/admin/drills carries a weak ETag and answers a repeat with 304 until a drill's result changes (#9205). */
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 { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { recordDrill } from "../src/core/domain_drills.ts";
10 import { DOMAINS } from "../src/kernel/domains.ts";
11 import { weakEtag } from "../src/http/etag.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 let dataDir = "";
15 let app: App, servers: Servers;
16 let admin = "", member = "", agentAdmin = "", admin2 = "";
17
18 const get = (token?: string, headers: Record<string, string> = {}) =>
19 fetch(`${servers.url}/api/v1/admin/drills`, {
20 headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers },
21 });
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-drills-etag-"));
25 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
26 servers = await serve(app);
27 const mk = async (username: string, extra: Record<string, unknown> = {}) => {
28 const u = await createUser(app.db, {
29 origin: "seed", trusted: true, username,
30 email: `${username}@forge.local`, password: "a-good-passphrase", ...extra,
31 });
32 if (!u.ok) throw new Error(u.error);
33 return u.value;
34 };
35 const alice = await mk("alice", { adminIfFirst: true });
36 const bob = await mk("bob");
37 const scribe = await mk("scribe", { agent: true, isAdmin: true });
38 const carol = await mk("carol", { isAdmin: true });
39 admin = createToken(app.db, alice.id, "a", "admin").token;
40 member = createToken(app.db, bob.id, "b", "admin").token;
41 agentAdmin = createToken(app.db, scribe.id, "c", "admin").token;
42 admin2 = createToken(app.db, carol.id, "d", "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 test("200 carries a weak tag over the body, private no-cache and vary; a repeat is a bodiless 304 with the floor", async () => {
51 const res = await get(admin);
52 expect(res.status).toBe(200);
53 expect(res.headers.get("content-type")).toBe("application/json");
54 const text = await res.text();
55 expect(Object.keys(JSON.parse(text))).toEqual(["items", "next_cursor"]);
56 const tag = res.headers.get("etag")!;
57 expect(tag).toBe(weakEtag(text));
58 expect(res.headers.get("cache-control")).toBe("private, no-cache");
59 expect(res.headers.get("vary")).toContain("authorization, cookie");
60 const again = await get(admin, { "if-none-match": tag });
61 expect(again.status).toBe(304);
62 expect(again.headers.get("etag")).toBe(tag);
63 expect(again.headers.get("cache-control")).toBe("private, no-cache");
64 expect(again.headers.get("vary")).toContain("authorization, cookie");
65 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
66 expect(again.headers.get("content-security-policy")).not.toBeNull();
67 expect(again.headers.get("referrer-policy")).not.toBeNull();
68 expect(await again.text()).toBe("");
69 });
70
71 test("a drill recorded moves the tag, and a stale tag gets the new body", async () => {
72 const first = await get(admin);
73 await first.text();
74 const before = first.headers.get("etag")!;
75 const d = DOMAINS.find((x) => x.drill.name !== null)!;
76 recordDrill(app.db, "alice", d.key, { health: "ok", board: "ok" } as any, 5, { green: true, why: "back in 5s" } as any);
77 const res = await get(admin, { "if-none-match": before });
78 expect(res.status).toBe(200);
79 const tag = res.headers.get("etag")!;
80 expect(tag).not.toBe(before);
81 expect(await res.text()).toContain("back in 5s");
82 recordDrill(app.db, "alice", d.key, { health: "ok", board: "ok" } as any, 9, { green: false, why: "stayed down" } as any);
83 const third = await get(admin, { "if-none-match": tag });
84 expect(third.status).toBe(200);
85 expect(third.headers.get("etag")).not.toBe(tag);
86 });
87
88 test("another admin shown the same body shares the tag; the tag is of the bytes, not the viewer", async () => {
89 const a = await get(admin);
90 const b = await get(admin2, { "if-none-match": a.headers.get("etag")! });
91 expect(b.status).toBe(304);
92 });
93
94 test("no session, a member and an agent admin answer as before with no ETag, even holding *", async () => {
95 const none = await get(undefined, { "if-none-match": "*" });
96 expect(none.status).toBe(401);
97 expect(none.headers.get("etag")).toBeNull();
98 for (const t of [member, agentAdmin]) {
99 const r = await get(t, { "if-none-match": "*" });
100 expect(r.status).toBe(403);
101 expect(r.headers.get("etag")).toBeNull();
102 }
103 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs