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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9226-get-api-v1-orgs-org-carries-an-etag-and → main 2 commits fork
2 files
changed +113
−1
src/http/api/orgs.ts +1 −1
| @@ -93,7 +93,7 @@ export function orgRoutes(router: Router<Handler>): void { | ||
| 93 | 93 | listTeamsVisibleTo(ctx.app.db, org.id, ctx.auth.user), |
| 94 | 94 | ); |
| 95 | 95 | return timePhase(ctx.request, "render", () => |
| 96 | json({ id: org.id, ...publicProfile(org), teams }), | |
| 96 | tagged(ctx.request, { id: org.id, ...publicProfile(org), teams }), | |
| 97 | 97 | ); |
| 98 | 98 | }); |
| 99 | 99 |
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 | }); |
new above
offline