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

GET /api/v1/repos/:owner/:name/egress-grants carries an ETag and answers a repeat with 304 until a grant is added, used or revoked #9184

Merged opened this

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

Merged

scout/forge:scout/9179-get-api-v1-repos-owner-name-egress-grant → main 2 commits fork

  • Merged. scout/9179-get-api-v1-repos-owner-name-egress-grant was merged into main as fd4d8ab.

Review

Sign in to review this change.
2 files changed +162 −2
src/http/api/egress_grants.ts +17 −2
@@ -7,10 +7,25 @@
77 import { egressView, grantAppEgress, revokeAppEgress } from "../../actions/egress_grants.ts";
88 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
99 import { loadRepo } from "../api.ts";
10 import type { Handler } from "../context.ts";
10 import type { Ctx, Handler } from "../context.ts";
11 import { holds, weakEtag } from "../etag.ts";
1112 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
1213 import type { Router } from "../router.ts";
1314
15 /**
16 * An admin's view of the grants, tagged by its own bytes (#9179):
17 * `private, no-cache` and varied on the credential, so a repeat holding the
18 * tag is a bodiless 304 until a grant or a change the view lists moves it.
19 * Called only after the admin check, so a stranger never reaches a 304.
20 */
21 function tagged(ctx: Ctx, data: unknown): Response {
22 const text = JSON.stringify(data);
23 const etag = weakEtag(text);
24 const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" };
25 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
26 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
27 }
28
1429 export function egressGrantRoutes(router: Router<Handler>): void {
1530 // A site admin's grant of one private destination forge.json asks for
1631 // (#3433). Reading takes admin over the repository; changing takes a site
@@ -26,7 +41,7 @@ export function egressGrantRoutes(router: Router<Handler>): void {
2641 );
2742 if (!view.ok) return respondAccess(view.error, 422);
2843 return timePhase(ctx.request, "render", () =>
29 json({ items: view.value.rows, changes: view.value.changes, next_cursor: null }),
44 tagged(ctx, { items: view.value.rows, changes: view.value.changes, next_cursor: null }),
3045 );
3146 });
3247
test/egress-grants-etag.test.ts added +145
@@ -0,0 +1,145 @@
1 /** GET …/egress-grants carries an ETag and answers a repeat with 304 (#9179). */
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 { commitToBranch, createRepo } from "../src/actions/repos.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 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let adminTok = "";
19 let readTok = "";
20 let otherTok = "";
21 const cells = process.env["FORGE_CELLS"];
22 const URL_ = "/api/v1/repos/alice/a/egress-grants";
23 const DEST = "100.89.95.68:8101";
24 const get = (path: string, tok: string, headers: Record<string, string> = {}) =>
25 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${tok}`, ...headers } });
26 const change = (method: "POST" | "DELETE", destination: string) =>
27 fetch(`${servers.url}${URL_}`, {
28 method,
29 headers: { authorization: `Bearer ${adminTok}`, "content-type": "application/json" },
30 body: JSON.stringify({ destination }),
31 });
32 const tagOf = async () => {
33 const res = await get(URL_, adminTok);
34 expect(res.status).toBe(200);
35 await res.text();
36 return res.headers.get("etag")!;
37 };
38 const moved = async (tag: string) => {
39 const res = await get(URL_, adminTok, { "if-none-match": tag });
40 expect(res.status).toBe(200);
41 const next = res.headers.get("etag")!;
42 expect(next).not.toBe(tag);
43 await res.text();
44 return next;
45 };
46
47 beforeAll(async () => {
48 process.env["FORGE_CELLS"] = "off";
49 dataDir = await mkdtemp(join(tmpdir(), "forge-egress-etag-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 const mk = async (username: string, first: boolean) => {
53 const made = await createUser(app.db, {
54 origin: "seed",
55 trusted: true,
56 username,
57 email: `${username}@forge.local`,
58 password: "a-good-passphrase",
59 adminIfFirst: first,
60 });
61 if (!made.ok) throw new Error(String(made.error));
62 return made.value;
63 };
64 const alice = await mk("alice", true);
65 const bob = await mk("bob", false);
66 const repo = await createRepo(app, alice, { name: "a", visibility: "public" });
67 if (!repo.ok) throw new Error("repo");
68 const done = await commitToBranch(app, alice, repo.value, {
69 message: "egress",
70 files: [
71 {
72 path: "forge.json",
73 content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [DEST] }),
74 },
75 ],
76 });
77 if (!done.ok) throw new Error(JSON.stringify(done.error));
78 adminTok = createToken(app.db, alice.id, "t", "admin").token;
79 readTok = createToken(app.db, alice.id, "r", "ro").token;
80 otherTok = createToken(app.db, bob.id, "o", "admin").token;
81 });
82 afterAll(async () => {
83 await servers.stop();
84 app.close();
85 if (cells === undefined) delete process.env["FORGE_CELLS"];
86 else process.env["FORGE_CELLS"] = cells;
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("a weak tag over the body, private no-cache, vary", async () => {
91 const res = await get(URL_, adminTok);
92 expect(res.status).toBe(200);
93 expect(res.headers.get("etag")).toMatch(/^W\/"/);
94 expect(res.headers.get("cache-control")).toBe("private, no-cache");
95 expect(res.headers.get("vary")).toContain("authorization, cookie");
96 expect(res.headers.get("content-type")).toContain("application/json");
97 const body = (await res.json()) as { items: { destination: string }[] };
98 expect(body.items.map((r) => r.destination)).toEqual([DEST]);
99 });
100
101 test("a repeat is a bodiless 304 with the floor", async () => {
102 const tag = await tagOf();
103 const again = await get(URL_, adminTok, { "if-none-match": tag });
104 expect(again.status).toBe(304);
105 expect(again.headers.get("etag")).toBe(tag);
106 expect(again.headers.get("cache-control")).toBe("private, no-cache");
107 expect(again.headers.get("vary")).toContain("authorization, cookie");
108 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
109 expect(again.headers.get("referrer-policy")).not.toBeNull();
110 expect(again.headers.get("content-security-policy")).not.toBeNull();
111 expect(await again.text()).toBe("");
112 });
113
114 test("granting and revoking move the tag", async () => {
115 let tag = await tagOf();
116 expect((await change("POST", DEST)).status).toBe(201);
117 tag = await moved(tag);
118 expect((await get(URL_, adminTok, { "if-none-match": tag })).status).toBe(304);
119 expect((await change("DELETE", DEST)).status).toBe(200);
120 await moved(tag);
121 });
122
123 test("a refused grant changes nothing the view lists, so the tag holds", async () => {
124 const tag = await tagOf();
125 const refused = await change("POST", "100.89.95.69:9999");
126 expect(refused.status).toBe(422);
127 await refused.text();
128 expect((await get(URL_, adminTok, { "if-none-match": tag })).status).toBe(304);
129 });
130
131 test("a token short of admin and a non-admin get no tag, even with *", async () => {
132 for (const tok of [readTok, otherTok]) {
133 const res = await get(URL_, tok, { "if-none-match": "*" });
134 expect(res.headers.get("etag")).toBeNull();
135 expect([403, 404]).toContain(res.status);
136 await res.text();
137 }
138 });
139
140 test("a missing repository is 404 without a tag, even with *", async () => {
141 const res = await get("/api/v1/repos/alice/nope/egress-grants", adminTok, { "if-none-match": "*" });
142 expect(res.status).toBe(404);
143 expect(res.headers.get("etag")).toBeNull();
144 await res.text();
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs