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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9018-a-test-proves-a-guest-pull-s-check-runs → main 1 commit fork
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 | ||
| 13 | 13 | secrets, a guest's or anyone's (`src/checks/service.ts` builds its |
| 14 | 14 | environment from the forge's own names only). |
| 15 | 15 | |
| 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 | ||
| 16 | 21 | The pull page says it is a guest to everyone; writers see **Approve this |
| 17 | 22 | guest**, a same-origin form posting to `/:owner/:name/pulls/:number/approve-guest`. |
| 18 | 23 | 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 | }); |
new above
offline