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

GET /api/v1/repos/:owner/:name/app and …/app/env carry an ETag and answer a repeat with 304 until the app or its environment changes #9232

Merged opened this

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

Merged

scout/forge:scout/9227-get-api-v1-repos-owner-name-app-and-app → main 2 commits fork

  • Merged. scout/9227-get-api-v1-repos-owner-name-app-and-app was merged into main as 11fca76.

Review

Sign in to review this change.
2 files changed +162 −2
src/http/api/app.ts +2 −2
@@ -72,7 +72,7 @@ export function appRoutes(router: Router<Handler>): void {
7272 });
7373 if (!read) return notFound();
7474 return timePhase(ctx.request, "render", () =>
75 json({
75 tagged(ctx, {
7676 ...appJson(ctx, read.view.app, repo, appsRuntime(ctx.app)),
7777 ...(read.vitals === undefined ? {} : { vitals: read.vitals }),
7878 }),
@@ -226,7 +226,7 @@ export function appRoutes(router: Router<Handler>): void {
226226 if (!repo) return notFound();
227227 const listed = timePhase(ctx.request, "env", () => listAppEnv(ctx.app, ctx.auth.user, repo));
228228 if (!listed.ok) return notFound();
229 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
229 return timePhase(ctx.request, "render", () => tagged(ctx, { items: listed.value, next_cursor: null }));
230230 });
231231
232232 router.add("PUT", "/api/v1/repos/:owner/:name/app/env/:var", async (ctx) => {
test/app-state-etag.test.ts added +160
@@ -0,0 +1,160 @@
1 /** GET …/app and …/app/env carry an ETag and answer a repeat with 304 until the app or its environment moves (#9227). */
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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
9 import { createApp as insertApp } from "../src/core/apps.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const APP = "/api/v1/repos/alice/notes/app";
18 const ENV = `${APP}/env`;
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let runtime: AppsRuntime;
24 let adminTok = "";
25 let roTok = "";
26 let bobTok = "";
27 let shown: EnvStatus[] = [];
28
29 const call = (method: string, path: string, tok: string | null, headers: Record<string, string> = {}, body?: unknown) =>
30 fetch(`${servers.url}${path}`, {
31 method,
32 headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json", ...headers },
33 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
34 });
35 const tagOf = async (path: string, tok = adminTok) => {
36 const res = await call("GET", path, tok);
37 expect(res.status).toBe(200);
38 await res.text();
39 return res.headers.get("etag")!;
40 };
41 const status = async (path: string, tag: string) => {
42 const res = await call("GET", path, adminTok, { "if-none-match": tag });
43 await res.text();
44 return res.status;
45 };
46 const env = (state: string, commitOid: string): EnvStatus =>
47 ({ env: "production", state, commitOid, url: "http://x.test" }) as EnvStatus;
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-app-state-etag-"));
51 app = createApp({ dataDir, port: 0, host: "forge.test" });
52 servers = await serve(app);
53 runtime = new AppsRuntime(app, {
54 domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1,
55 });
56 await runtime.start();
57 runtime.status = () => shown;
58 const mk = async (username: string, first: boolean) => {
59 const made = await createUser(app.db, {
60 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
61 });
62 if (!made.ok) throw new Error(String(made.error));
63 return made.value;
64 };
65 const alice = await mk("alice", true);
66 const bob = await mk("bob", false);
67 const repo = await createRepo(app, alice, { name: "notes", visibility: "public", description: "" });
68 if (!repo.ok) throw new Error("repo");
69 const row = insertApp(app.db, { repoId: repo.value.id, slug: "notes", access: "public" });
70 if (!row.ok) throw new Error(row.error);
71 adminTok = createToken(app.db, alice.id, "a", "admin").token;
72 roTok = createToken(app.db, alice.id, "r", "ro").token;
73 bobTok = createToken(app.db, bob.id, "b", "admin").token;
74 });
75 afterAll(async () => {
76 await runtime?.stop();
77 servers?.stop();
78 app?.close();
79 if (dataDir) await rm(dataDir, { recursive: true, force: true });
80 });
81
82 for (const path of [APP, ENV]) {
83 test(`${path}: weak tag, private no-cache, vary; a repeat is a bodiless 304 with the floor`, async () => {
84 const res = await call("GET", path, adminTok);
85 expect(res.status).toBe(200);
86 const tag = res.headers.get("etag")!;
87 expect(tag).toMatch(/^W\/"/);
88 expect(res.headers.get("cache-control")).toBe("private, no-cache");
89 expect(res.headers.get("vary")).toContain("authorization, cookie");
90 expect(res.headers.get("content-type")).toContain("application/json");
91 await res.json();
92 const again = await call("GET", path, adminTok, { "if-none-match": tag });
93 expect(again.status).toBe(304);
94 expect(again.headers.get("etag")).toBe(tag);
95 expect(again.headers.get("cache-control")).toBe("private, no-cache");
96 expect(again.headers.get("vary")).toContain("authorization, cookie");
97 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
98 expect(again.headers.get("referrer-policy")).not.toBeNull();
99 expect(again.headers.get("content-security-policy")).not.toBeNull();
100 expect(await again.text()).toBe("");
101 });
102 }
103
104 test("starting, stopping and deploying a new commit each move the /app tag", async () => {
105 shown = [];
106 const none = await tagOf(APP);
107 shown = [env("running", "a".repeat(40))];
108 const started = await tagOf(APP);
109 expect(started).not.toBe(none);
110 expect(await status(APP, started)).toBe(304);
111 expect(await status(APP, none)).toBe(200);
112 shown = [env("running", "b".repeat(40))];
113 const deployed = await tagOf(APP);
114 expect(deployed).not.toBe(started);
115 shown = [env("stopped", "b".repeat(40))];
116 const stopped = await tagOf(APP);
117 expect(stopped).not.toBe(deployed);
118 shown = [];
119 expect(await tagOf(APP)).toBe(none);
120 });
121
122 test("setting and removing a variable move the /env tag; the value never feeds it", async () => {
123 const empty = await tagOf(ENV);
124 const set = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-one" });
125 expect(set.status).toBe(200);
126 await set.text();
127 const added = await tagOf(ENV);
128 expect(added).not.toBe(empty);
129 expect(await status(ENV, added)).toBe(304);
130 const again = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-two" });
131 await again.text();
132 const body = await (await call("GET", ENV, adminTok)).text();
133 expect(body).not.toContain("secret");
134 const gone = await call("DELETE", `${ENV}/GREETING`, adminTok);
135 expect(gone.status).toBe(200);
136 await gone.text();
137 expect(await tagOf(ENV)).toBe(empty);
138 });
139
140 test("a body that differs for another viewer does not share the tag", async () => {
141 const mine = await tagOf(APP);
142 const theirs = await tagOf(APP, bobTok);
143 expect(theirs).not.toBe(mine);
144 expect(await status(APP, theirs)).toBe(200);
145 });
146
147 test("refusals and a missing repository answer as before, with no tag, even for *", async () => {
148 for (const [token, path, code] of [
149 [roTok, ENV, 403],
150 [bobTok, ENV, 404],
151 [null, ENV, 401],
152 [adminTok, "/api/v1/repos/alice/nope/app", 404],
153 [adminTok, "/api/v1/repos/alice/nope/app/env", 404],
154 ] as const) {
155 const res = await call("GET", path, token, { "if-none-match": "*" });
156 expect(res.status).toBe(code);
157 expect(res.headers.get("etag")).toBeNull();
158 await res.text();
159 }
160 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs