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

A test proves a guest pull's check runs with no secrets, and has them after a person approves #9038

Merged opened this

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

Merged

torque/forge:torque/9018-a-test-proves-a-guest-pull-s-check-runs → main 1 commit fork

  • Merged. torque/9018-a-test-proves-a-guest-pull-s-check-runs was merged into main as 38c678c.

Review

Sign in to review this change.
2 files changed +195 −0
docs/features/guest-pulls.md +5
@@ -13,6 +13,11 @@ Its check needs nothing here: a check never carries the repository's
1313 secrets, a guest's or anyone's (`src/checks/service.ts` builds its
1414 environment from the forge's own names only).
1515
16 `test/guest-pull-env.test.ts` proves both from the processes themselves: a
17 guest's real check prints its environment and none of the app's variables
18 or a forge token is in it, before approval and after; its real preview
19 answers with neither until a writer approves, then with both.
20
1621 The pull page says it is a guest to everyone; writers see **Approve this
1722 guest**, a same-origin form posting to `/:owner/:name/pulls/:number/approve-guest`.
1823 In the terminal the pull screen says the same, and a writer presses `G`.
test/guest-pull-env.test.ts added +190
@@ -0,0 +1,190 @@
1 /**
2 * A guest pull's processes, end to end (#9018): a pull whose author lives on
3 * another forge runs its real check and its real preview here, and what each
4 * process holds is read from the process itself, not from the function that
5 * builds it. The check holds none of the app's variables and no forge token,
6 * before a writer approves the guest and after: a check never carries the
7 * repository's secrets (`checks/service.ts` names its environment from the
8 * forge's own words, through `childEnv`). The preview holds neither until a
9 * writer approves, then both (`AppsRuntime.secretsFor`).
10 */
11
12 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13 import { newApp } from "../src/actions/apps.ts";
14 import { approveGuestPull, openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch } from "../src/actions/repos.ts";
16 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
17 import { setAppEnv } from "../src/core/app_env.ts";
18 import { getPull, type Pull } from "../src/core/pulls.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { listRuns, type Run } from "../src/core/runs.ts";
21 import { guestWaits } from "../src/origins/guest.ts";
22 import { readRunLog } from "../src/runs/log.ts";
23 import {
24 deploySettled,
25 fetchAsHost,
26 startAppsForge,
27 stopAppsForge,
28 type AppsForge,
29 } from "./helpers/apps-forge.ts";
30 import { quietLogs } from "./helpers/globals.ts";
31
32 quietLogs();
33
34 const SECRET = "s3cret-9018";
35
36 /** The app: a check that prints its whole environment, a server that says what it holds. */
37 const FILES = [
38 {
39 path: "forge.json",
40 content: `${JSON.stringify({
41 app: { run: "bun run src/server.ts", health: "/healthz" },
42 check: { run: "env", install: "" },
43 })}\n`,
44 },
45 {
46 path: "src/server.ts",
47 content: `Bun.serve({
48 port: Number(process.env.PORT),
49 hostname: "127.0.0.1",
50 fetch() {
51 return Response.json({
52 apiKey: process.env.API_KEY ?? null,
53 forgeToken: (process.env.FORGE_TOKEN ?? "") !== "",
54 });
55 },
56 });
57 `,
58 },
59 ];
60
61 describe("a guest pull's check and preview, read from the processes themselves", () => {
62 let forge: AppsForge;
63 let checks: ChecksHandle;
64 let repo: Repo;
65 let slug: string;
66
67 async function checkSettled(env: string, commitOid: string): Promise<Run> {
68 const deadline = Date.now() + 60_000;
69 while (Date.now() < deadline) {
70 const run = listRuns(forge.app.db, repo.id, { kind: "check", limit: 50 }).find(
71 (r) => r.env === env && r.commitOid === commitOid,
72 );
73 if (run !== undefined && (run.state === "ok" || run.state === "failed" || run.state === "cancelled"))
74 return run;
75 await Bun.sleep(100);
76 }
77 throw new Error(`the check of ${commitOid.slice(0, 7)} did not settle`);
78 }
79
80 /** What the check's process printed of its environment. */
81 const checkLog = (run: Run) => readRunLog(forge.dataDir, run.id).lines.join("\n");
82
83 async function heldByPreview(pull: Pull): Promise<{ apiKey: string | null; forgeToken: boolean }> {
84 const res = await fetchAsHost(forge.servers, `${slug}--${pull.number}.${forge.runtime.options.domain}`, "/", {
85 headers: { accept: "application/json" },
86 });
87 expect(res.status).toBe(200);
88 return (await res.json()) as { apiKey: string | null; forgeToken: boolean };
89 }
90
91 beforeAll(async () => {
92 forge = await startAppsForge();
93 checks = startChecks(forge.app, { forgeUrl: forge.servers.url, concurrency: 1 });
94 const made = await newApp(forge.app, forge.alice, {
95 name: "vault",
96 description: "Holds a key",
97 visibility: "public",
98 });
99 if (!made.ok) throw new Error(String(made.error));
100 repo = made.value.repo;
101 slug = made.value.app.slug;
102 const set = setAppEnv(forge.app.db, forge.dataDir, made.value.app.id, "API_KEY", SECRET);
103 if (!set.ok) throw new Error(set.error);
104 const committed = await commitToBranch(forge.app, forge.alice, repo, {
105 message: "Say what it holds",
106 files: FILES,
107 });
108 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
109 const prod = await deploySettled(
110 forge.app,
111 repo.id,
112 (r) => r.env === "production" && r.commitOid === committed.value.commitOid,
113 );
114 if (prod.state !== "ok") throw new Error(prod.summary);
115 }, 120_000);
116
117 afterAll(async () => {
118 await checks.stop();
119 await forge.runtime.stop();
120 await stopAppsForge(forge);
121 });
122
123 test("the check holds nothing secret before or after approval; the preview holds the secrets only after", async () => {
124 const { app, alice } = forge;
125 const branch = "from-elsewhere";
126 const first = await commitToBranch(app, alice, repo, {
127 branch,
128 message: "From another forge",
129 files: [{ path: "theirs.md", content: "one\n" }],
130 });
131 if (!first.ok) throw new Error(JSON.stringify(first.error));
132 // The pull is born a guest, as one brought in from the origin is: its
133 // issue names an origin author before its check or preview is asked for.
134 app.db.run(
135 `CREATE TEMP TRIGGER guest_at_birth AFTER INSERT ON issue WHEN NEW.kind = 'pull'
136 BEGIN UPDATE issue SET origin_author = 'octocat' WHERE id = NEW.id; END`,
137 );
138 let pull: Pull;
139 try {
140 const opened = await openPull(app, alice, repo, {
141 title: "Theirs",
142 baseBranch: "main",
143 headBranch: branch,
144 });
145 if (!opened.ok) throw new Error(String(opened.error));
146 pull = getPull(app.db, repo.id, opened.value.number)!;
147 } finally {
148 app.db.run("DROP TRIGGER guest_at_birth");
149 }
150 expect(guestWaits(app.db, pull)).toBe(true);
151 const env = `pull-${pull.number}`;
152
153 // Waiting: the check ran and printed its environment, none of it secret.
154 const before = await checkSettled(env, first.value.commitOid);
155 expect(before.state).toBe("ok");
156 const printed = checkLog(before);
157 expect(printed).toContain(`FORGE_CHECK=${before.id}`);
158 expect(printed).not.toContain(SECRET);
159 expect(printed).not.toContain("API_KEY=");
160 expect(printed).not.toContain("FORGE_TOKEN=");
161 const waiting = await deploySettled(app, repo.id, (r) => r.env === env && r.commitOid === first.value.commitOid);
162 expect(waiting.state).toBe("ok");
163 expect(await heldByPreview(pull)).toEqual({ apiKey: null, forgeToken: false });
164
165 // A writer approves: the preview starts again holding both.
166 expect(approveGuestPull(app, alice, repo, pull)).toEqual({ ok: true, value: "approved" });
167 const restarted = await deploySettled(app, repo.id, (r) => r.env === env && r.id !== waiting.id);
168 expect(restarted.state).toBe("ok");
169 expect(await heldByPreview(pull)).toEqual({ apiKey: SECRET, forgeToken: true });
170
171 // The guest pushes again: its check still holds nothing secret, by
172 // design, while its preview keeps the approval.
173 const second = await commitToBranch(app, alice, repo, {
174 branch,
175 message: "And more",
176 files: [{ path: "theirs.md", content: "two\n" }],
177 });
178 if (!second.ok) throw new Error(JSON.stringify(second.error));
179 const after = await checkSettled(env, second.value.commitOid);
180 expect(after.state).toBe("ok");
181 const printedAfter = checkLog(after);
182 expect(printedAfter).toContain(`FORGE_CHECK=${after.id}`);
183 expect(printedAfter).not.toContain(SECRET);
184 expect(printedAfter).not.toContain("API_KEY=");
185 expect(printedAfter).not.toContain("FORGE_TOKEN=");
186 const pushed = await deploySettled(app, repo.id, (r) => r.env === env && r.commitOid === second.value.commitOid);
187 expect(pushed.state).toBe("ok");
188 expect(await heldByPreview(pull)).toEqual({ apiKey: SECRET, forgeToken: true });
189 }, 180_000);
190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs