Public: anyone can read this; the crew can change it.
A test pins `ssh <app>@<forge> pulls`: a title cannot carry a terminal escape, and a refused viewer gets the terminal's own refusal #6834
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6825-a-test-pins-ssh-app-forge-pulls-a-title → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +132
−0
test/tty-pulls.test.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * `ssh <app>@<forge> pulls` (#6825): a title is text anyone with write can | |
| 3 | * set, so it reaches a terminal as one plain line; a refused viewer gets the | |
| 4 | * terminal's own refusal on stderr and nothing on stdout. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { ttyRefusalText } from "../src/apps/tty.ts"; | |
| 13 | import { plainTitle, servePulls } from "../src/apps/tty_pulls.ts"; | |
| 14 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | describe("plainTitle", () => { | |
| 20 | test("drops ESC sequences, newlines, line separators and zero-width characters", () => { | |
| 21 | expect(plainTitle("a\x1b[31mred\x1b[0m")).toBe("a[31mred[0m"); | |
| 22 | expect(plainTitle("a\x1b]0;owned\x07b")).toBe("a]0;ownedb"); | |
| 23 | expect(plainTitle("one\r\ntwo\nthree")).toBe("onetwothree"); | |
| 24 | expect(plainTitle("x\u2028y\u2029z")).toBe("xyz"); | |
| 25 | expect(plainTitle("z\u200bw\u200d\u2060\ufeffj")).toBe("zwj"); | |
| 26 | expect(plainTitle("\x9b31mc1")).toBe("31mc1"); | |
| 27 | }); | |
| 28 | ||
| 29 | test("trims and bounds the line at 120 characters", () => { | |
| 30 | expect(plainTitle(" padded ")).toBe("padded"); | |
| 31 | expect(plainTitle("a".repeat(500))).toBe("a".repeat(120)); | |
| 32 | }); | |
| 33 | }); | |
| 34 | ||
| 35 | describe("servePulls", () => { | |
| 36 | let dataDir: string; | |
| 37 | let app: App; | |
| 38 | let owner: User; | |
| 39 | let stranger: User; | |
| 40 | let pub: { app: AppRecord; repo: Repo }; | |
| 41 | let priv: { app: AppRecord; repo: Repo }; | |
| 42 | ||
| 43 | const pull = (r: Repo, n: number, title: string, state?: string) => { | |
| 44 | const id = crypto.randomUUID(); | |
| 45 | app.db | |
| 46 | .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?, ?, ?, 'pull', ?, ?)") | |
| 47 | .run(id, r.id, n, title, owner.id); | |
| 48 | app.db | |
| 49 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)") | |
| 50 | .run(id, `h${n}`, `head${n}`); | |
| 51 | if (state) | |
| 52 | app.db | |
| 53 | .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)") | |
| 54 | .run(crypto.randomUUID(), r.id, id, `head${n}`, state); | |
| 55 | }; | |
| 56 | ||
| 57 | const person = async (username: string, adminIfFirst = false) => { | |
| 58 | const made = await createUser(app.db, { | |
| 59 | origin: "seed", | |
| 60 | trusted: true, | |
| 61 | username, | |
| 62 | email: `${username}@f.dev`, | |
| 63 | password: "a-good-passphrase", | |
| 64 | adminIfFirst, | |
| 65 | }); | |
| 66 | if (!made.ok) throw new Error(made.error); | |
| 67 | promote(app.db, made.value); | |
| 68 | return made.value; | |
| 69 | }; | |
| 70 | ||
| 71 | const fake = (username: string) => { | |
| 72 | const s = { out: "", err: "", code: undefined as number | undefined }; | |
| 73 | const session = { | |
| 74 | username, | |
| 75 | write: (b: Uint8Array) => void (s.out += new TextDecoder().decode(b)), | |
| 76 | writeStderr: (d: string | Uint8Array) => | |
| 77 | void (s.err += typeof d === "string" ? d : new TextDecoder().decode(d)), | |
| 78 | exit: (c: number) => void (s.code = c), | |
| 79 | }; | |
| 80 | return { s, session: session as never }; | |
| 81 | }; | |
| 82 | ||
| 83 | beforeAll(async () => { | |
| 84 | dataDir = await mkdtemp(join(tmpdir(), "forge-tty-pulls-")); | |
| 85 | app = createApp({ dataDir, port: 0 }); | |
| 86 | owner = await person("olive", true); | |
| 87 | stranger = await person("eve"); | |
| 88 | const a = await newApp(app, owner, { name: "hello", description: "", visibility: "public" }); | |
| 89 | if (!a.ok) throw new Error(String(a.error)); | |
| 90 | pub = a.value; | |
| 91 | const b = await newApp(app, owner, { name: "secret", description: "", visibility: "private" }); | |
| 92 | if (!b.ok) throw new Error(String(b.error)); | |
| 93 | priv = b.value; | |
| 94 | pull(pub.repo, 1, "Plain", "ok"); | |
| 95 | pull(pub.repo, 2, "Evil \x1b[2J\x1b]0;pwned\x07title\r\nfake line ", "failed"); | |
| 96 | pull(pub.repo, 3, "Quiet"); | |
| 97 | pull(priv.repo, 1, "Hidden"); | |
| 98 | }); | |
| 99 | ||
| 100 | afterAll(async () => { | |
| 101 | app.close(); | |
| 102 | await rm(dataDir, { recursive: true, force: true }); | |
| 103 | }); | |
| 104 | ||
| 105 | test("writes one `#n title · check` line per open pull and exits 0", () => { | |
| 106 | const { s, session } = fake("hello"); | |
| 107 | servePulls(app, session, owner, { app: pub.app, env: "production" }); | |
| 108 | expect(s.out).toBe("#3 Quiet · none\n#2 Evil [2J]0;pwnedtitlefake line · red\n#1 Plain · green\n"); | |
| 109 | expect(s.err).toBe(""); | |
| 110 | expect(s.code).toBe(0); | |
| 111 | }); | |
| 112 | ||
| 113 | test("no title puts an escape or a stray newline on the wire", () => { | |
| 114 | const { s, session } = fake("hello"); | |
| 115 | servePulls(app, session, undefined, { app: pub.app, env: "production" }); | |
| 116 | expect(s.out).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f\u2028\u2029]/); | |
| 117 | expect(s.out.split("\n")).toHaveLength(4); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a viewer the gate refuses gets the terminal's refusal on stderr alone", () => { | |
| 121 | const { s, session } = fake("secret"); | |
| 122 | servePulls(app, session, stranger, { app: priv.app, env: "production" }); | |
| 123 | expect(s.out).toBe(""); | |
| 124 | expect(s.err).toBe(`forge: ${ttyRefusalText("not_found", "secret")}\r\n`); | |
| 125 | expect(s.code).toBe(1); | |
| 126 | const guest = fake("secret"); | |
| 127 | servePulls(app, guest.session, undefined, { app: priv.app, env: "production" }); | |
| 128 | expect(guest.s.out).toBe(""); | |
| 129 | expect(guest.s.err).toBe(`forge: ${ttyRefusalText("not_found", "secret")}\r\n`); | |
| 130 | expect(guest.s.code).toBe(1); | |
| 131 | }); | |
| 132 | }); |
new above
offline
Assert the literal string
forge: no app named secret\r\nat least once, so the test pins the wording and not just the helper.