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

GET /api/v1/orgs/:org carries an ETag and answers a repeat with 304 until the organisation changes #9237

Merged opened this

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

Merged

mason/forge:mason/9226-get-api-v1-orgs-org-carries-an-etag-and → main 2 commits fork

  • Merged. mason/9226-get-api-v1-orgs-org-carries-an-etag-and was merged into main as 6ef489a.

Review

Sign in to review this change.
2 files changed +113 −1
src/http/api/orgs.ts +1 −1
@@ -93,7 +93,7 @@ export function orgRoutes(router: Router<Handler>): void {
9393 listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user),
9494 );
9595 return timePhase(ctx.request, "render", () =>
96 json({ id: org.id, ...publicProfile(org), teams }),
96 tagged(ctx.request, { id: org.id, ...publicProfile(org), teams }),
9797 );
9898 });
9999
test/org-etag.test.ts added +112
@@ -0,0 +1,112 @@
1 /** GET /api/v1/orgs/:org carries an ETag; a repeat is a 304 until the organisation's body changes. */
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 { addTeamMember, createOrg, createTeam, listTeams } from "../src/core/orgs.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 describe("org ETag", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let owner: string;
17 let member: string;
18 let memberId: string;
19 let orgId: string;
20 const O = "/api/v1/orgs/acme";
21
22 const get = (path: string, token: string | null, extra: Record<string, string> = {}) =>
23 fetch(`${servers.url}${path}`, {
24 headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra },
25 });
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-org-etag-"));
29 app = createApp({ dataDir, port: 0 });
30 servers = await serve(app);
31 const mk = async (username: string) => {
32 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
33 if (!u.ok) throw new Error(u.error);
34 return u.value;
35 };
36 const a = await mk("alice");
37 const b = await mk("bob");
38 memberId = b.id;
39 owner = createToken(app.db, a.id, "admin", "admin").token;
40 member = createToken(app.db, b.id, "admin", "admin").token;
41 const org = createOrg(app.db, "acme", a.id);
42 if (!org.ok) throw new Error(org.error);
43 orgId = org.value.id;
44 const t = createTeam(app.db, orgId, "docs", "write");
45 if (!t.ok) throw new Error(t.error);
46 }, 30_000);
47
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 test("200 with a weak tag, then 304 with the same headers and no body", async () => {
55 const first = await get(O, owner);
56 expect(first.status).toBe(200);
57 const tag = first.headers.get("etag")!;
58 expect(tag).toMatch(/^W\/"/);
59 expect(first.headers.get("cache-control")).toBe("private, no-cache");
60 expect(first.headers.get("vary")).toContain("authorization, cookie");
61 expect(first.headers.get("content-type")).toContain("application/json");
62 const text = await first.text();
63 expect(JSON.parse(text).username).toBe("acme");
64 const again = await get(O, owner, { "if-none-match": tag });
65 expect(again.status).toBe(304);
66 expect(again.headers.get("etag")).toBe(tag);
67 expect(again.headers.get("cache-control")).toBe("private, no-cache");
68 expect(again.headers.get("vary")).toContain("authorization, cookie");
69 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
70 expect(again.headers.get("referrer-policy")).not.toBeNull();
71 expect(await again.text()).toBe("");
72 });
73
74 test("the tag moves when the name shown or the teams shown change", async () => {
75 const t0 = (await get(O, owner)).headers.get("etag")!;
76 app.db.query("UPDATE user SET display_name = ?1 WHERE id = ?2").run("Acme Inc", orgId);
77 const renamed = await get(O, owner, { "if-none-match": t0 });
78 expect(renamed.status).toBe(200);
79 const t1 = renamed.headers.get("etag")!;
80 expect(t1).not.toBe(t0);
81 expect((await get(O, owner, { "if-none-match": t1 })).status).toBe(304);
82 expect(createTeam(app.db, orgId, "ops", "read").ok).toBe(true);
83 const grown = await get(O, owner, { "if-none-match": t1 });
84 expect(grown.status).toBe(200);
85 expect(grown.headers.get("etag")).not.toBe(t1);
86 });
87
88 test("a member joining a team moves that member's tag, and viewers shown different bodies never share one", async () => {
89 const ownerTag = (await get(O, owner)).headers.get("etag")!;
90 const m0 = await get(O, member);
91 expect(m0.status).toBe(200);
92 const memberTag = m0.headers.get("etag")!;
93 expect(memberTag).not.toBe(ownerTag);
94 expect((await get(O, member, { "if-none-match": ownerTag })).status).toBe(200);
95 const team = listTeams(app.db, orgId).find((t) => t.name === "docs")!;
96 expect(addTeamMember(app.db, team, memberId)).toBe(true);
97 const after = await get(O, member, { "if-none-match": memberTag });
98 expect(after.status).toBe(200);
99 expect(after.headers.get("etag")).not.toBe(memberTag);
100 });
101
102 test("an unknown organisation is 404 with no tag, even for If-None-Match: *", async () => {
103 const tag = (await get(O, owner)).headers.get("etag")!;
104 for (const held of [tag, "*"]) {
105 for (const who of [owner, null]) {
106 const out = await get("/api/v1/orgs/nope", who, { "if-none-match": held });
107 expect(out.status).toBe(404);
108 expect(out.headers.get("etag")).toBeNull();
109 }
110 }
111 });
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs