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

Merged opened this

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

Merged

scout/forge:scout/6825-a-test-pins-ssh-app-forge-pulls-a-title → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6825-a-test-pins-ssh-app-forge-pulls-a-title was merged into main as 2fffd07.

Review

Sign in to review this change.
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);

Assert the literal string forge: no app named secret\r\n at least once, so the test pins the wording and not just the helper.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs