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

GET /api/v1/cloud/accounts carries an ETag and answers a repeat with 304 until a cloud account or its use changes #9235

Merged opened this

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

Merged

scout/forge:scout/9206-get-api-v1-cloud-accounts-carries-an-eta → main 2 commits fork

  • Merged. scout/9206-get-api-v1-cloud-accounts-carries-an-eta was merged into main as 52b110d.

Review

Sign in to review this change.
2 files changed +160 −2
src/http/api/cloud.ts +10 −2
@@ -10,7 +10,8 @@ import { cloudOverview } from "../../cloud/overview.ts";
1010 import { scopeAllows } from "../../core/tokens.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1212 import type { Handler } from "../context.ts";
13 import { apiError, json, notFound, unauthorized } from "../respond.ts";
13 import { holds, weakEtag } from "../etag.ts";
14 import { apiError, notFound, unauthorized } from "../respond.ts";
1415 import type { Router } from "../router.ts";
1516
1617 export function cloudRoutes(router: Router<Handler>): void {
@@ -25,6 +26,13 @@ export function cloudRoutes(router: Router<Handler>): void {
2526 () => new Map<string, VendorUse[]>(),
2627 );
2728 const seen = cloudOverview(ctx.app, user, uses);
28 return seen.ok ? json(seen.value) : notFound();
29 if (!seen.ok) return notFound();
30 // Tagged over the exact bytes, so a linked or removed account, a call, or a
31 // repository starting or stopping a vendor moves it; reached past the access checks only.
32 const text = JSON.stringify(seen.value);
33 const etag = weakEtag(text);
34 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
35 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
36 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
2937 });
3038 }
test/cloud-accounts-etag.test.ts added +150
@@ -0,0 +1,150 @@
1 /** GET /api/v1/cloud/accounts carries a weak ETag and answers a repeat with 304 until an account or its use changes (#9206). */
2 import { afterAll, beforeAll, expect, spyOn, 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 { addAccount, net, removeAccount } from "../src/actions/cloud.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createRepo } from "../src/core/repos.ts";
10 import { createUser, getUserByName, type User } from "../src/core/users.ts";
11 import { repoDir } from "../src/git/repos.ts";
12 import { commitFiles } from "../src/git/write.ts";
13 import { weakEtag } from "../src/http/etag.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18 net.fetch = async () => new Response(JSON.stringify({ login: "octo" }), { status: 200 });
19
20 let dataDir = "";
21 let app: App, servers: Servers;
22 let alice: User;
23 let adminTok = "", otherAdminTok = "", roTok = "", bobTok = "";
24 let clock = Date.now();
25 const asAdmin = (headers: Record<string, string> = {}, token = adminTok) =>
26 fetch(`${servers.url}/api/v1/cloud/accounts`, { headers: { authorization: `Bearer ${token}`, ...headers } });
27 const tagOf = async (token = adminTok) => {
28 const r = await asAdmin({}, token);
29 expect(r.status).toBe(200);
30 await r.text();
31 return r.headers.get("etag")!;
32 };
33 /** Moves past the one-minute declaration cache. */
34 const later = () => {
35 clock += 61_000;
36 };
37 const declare = (owner: string, name: string, uses: unknown[]) =>
38 commitFiles(repoDir(app.reposRoot, owner, name), {
39 branch: "main",
40 message: "cloud",
41 files: [{ path: "cloud.json", content: JSON.stringify({ uses }) }],
42 author: { name: "T", email: "[email protected]" },
43 });
44
45 beforeAll(async () => {
46 spyOn(Date, "now").mockImplementation(() => clock);
47 dataDir = await mkdtemp(join(tmpdir(), "forge-cloudetag-"));
48 app = createApp({ dataDir, port: 0 });
49 servers = await serve(app);
50 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
51 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
52 if (!made.ok) throw new Error(made.error);
53 }
54 alice = getUserByName(app.db, "alice")!;
55 const carol = getUserByName(app.db, "carol")!;
56 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(carol.id);
57 adminTok = createToken(app.db, alice.id, "admin", "admin").token;
58 roTok = createToken(app.db, alice.id, "ro", "ro").token;
59 bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "bob", "admin").token;
60 otherAdminTok = createToken(app.db, carol.id, "carol", "admin").token;
61 const added = await addAccount(app, alice, "github", "main", "ghp_first9Zq4TxLm7Kp2Vb8nWcR5");
62 if (!added.ok) throw new Error(String(added.error));
63 }, 30_000);
64 afterAll(async () => {
65 servers.stop();
66 app.close();
67 await rm(dataDir, { recursive: true, force: true });
68 });
69
70 test("200 carries a weak tag, private no-cache and vary; a repeat is a bodiless 304 with the floor headers", async () => {
71 const res = await asAdmin();
72 expect(res.status).toBe(200);
73 const tag = res.headers.get("etag")!;
74 expect(tag).toMatch(/^W\//);
75 expect(res.headers.get("cache-control")).toBe("private, no-cache");
76 expect(res.headers.get("vary")).toContain("authorization, cookie");
77 expect(res.headers.get("content-type")).toBe("application/json");
78 expect(JSON.parse(await res.text()).accounts).toHaveLength(1);
79 const again = await asAdmin({ "if-none-match": tag });
80 expect(again.status).toBe(304);
81 expect(again.headers.get("etag")).toBe(tag);
82 expect(again.headers.get("cache-control")).toBe("private, no-cache");
83 expect(again.headers.get("vary")).toContain("authorization, cookie");
84 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
85 expect(again.headers.get("content-security-policy")).not.toBeNull();
86 expect(again.headers.get("referrer-policy")).not.toBeNull();
87 expect(await again.text()).toBe("");
88 const stale = await asAdmin({ "if-none-match": 'W/"nope"' });
89 expect(stale.status).toBe(200);
90 });
91
92 test("an account linked or removed, and a repository starting or stopping a vendor, move the tag", async () => {
93 let t = await tagOf();
94 const moved = async (what: string) => {
95 later();
96 const next = await tagOf();
97 expect(next, what).not.toBe(t);
98 expect((await asAdmin({ "if-none-match": next })).status, what).toBe(304);
99 t = next;
100 };
101 expect((await asAdmin({ "if-none-match": t })).status).toBe(304);
102
103 const added = await addAccount(app, alice, "github", "second", "ghp_second9Zq4TxLm7Kp2Vb8nWcR5");
104 expect(added.ok).toBe(true);
105 await moved("linked");
106
107 const repo = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "site", visibility: "private" });
108 if (!repo.ok) throw new Error("repo");
109 await declare("alice", "site", [{ vendor: "github", use: "cdn", why: "builds" }]);
110 await moved("a repository starts a vendor");
111 await declare("alice", "site", []);
112 await moved("a repository stops a vendor");
113
114 const removed = removeAccount(app, alice, "github", "second");
115 expect(removed.ok).toBe(true);
116 await moved("removed");
117 });
118
119 test("the tag is of the exact bytes sent, so viewers shown different bodies cannot share one", async () => {
120 const carol = getUserByName(app.db, "carol")!;
121 const repo = await createRepo(app.db, app.reposRoot, { ownerId: carol.id, name: "hers", visibility: "private" });
122 if (!repo.ok) throw new Error("repo");
123 await declare("carol", "hers", [{ vendor: "github", use: "cdn", why: "hers alone" }]);
124 later();
125 for (const token of [adminTok, otherAdminTok]) {
126 const res = await asAdmin({}, token);
127 const text = await res.text();
128 expect(res.headers.get("etag")).toBe(weakEtag(text));
129 }
130 // A tag over any other bytes is not held.
131 const old = await asAdmin({ "if-none-match": weakEtag("{}") });
132 expect(old.status).toBe(200);
133 });
134
135 test("no session, a read-only token and a non-admin answer as before, with no tag, even holding If-None-Match: *", async () => {
136 const star = { "if-none-match": "*" };
137 const anon = await fetch(`${servers.url}/api/v1/cloud/accounts`, { headers: star });
138 expect(anon.status).toBe(401);
139 expect(anon.headers.get("etag")).toBeNull();
140 const ro = await asAdmin(star, roTok);
141 expect(ro.status).toBe(403);
142 expect(ro.headers.get("etag")).toBeNull();
143 const plain = await asAdmin({}, bobTok);
144 const bob = await asAdmin(star, bobTok);
145 expect(bob.status).toBe(404);
146 expect(bob.status).toBe(plain.status);
147 expect(await bob.text()).toBe(await plain.text());
148 expect(bob.headers.get("etag")).toBeNull();
149 expect((await asAdmin(star)).status).toBe(304);
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs