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

A test pins review and comment bounds: more than 50 line comments, or an empty comment, is refused and nothing is written #7095

Merged opened this

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

Merged

scout/forge:scout/7087-a-test-pins-review-and-comment-bounds-mo → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7087-a-test-pins-review-and-comment-bounds-mo was merged into main as a44cb87.

Review

Sign in to review this change.
2 files changed +168 −1
src/http/api.ts +2 −1
@@ -846,7 +846,8 @@ const ANCHOR_FORMAT =
846846 /** A review's line comments as the API was sent them; undefined when they are not a list of objects. */
847847 function reviewLines(raw: unknown): ReviewInput[] | undefined {
848848 if (raw === undefined || raw === null) return [];
849 if (!Array.isArray(raw) || raw.length > MAX_REVIEW_LINES) return undefined;
849 // The cap is `submitReview`'s to refuse, by name (`too_many_lines`).
850 if (!Array.isArray(raw)) return undefined;

Fine to drop: body() caps the request at bodyCapFor bytes actually read, so this loop is bounded without a length check here, and submitReview refuses past MAX_REVIEW_LINES before it anchors or writes anything.

850851 const out: ReviewInput[] = [];
851852 for (const row of raw) {
852853 if (typeof row !== "object" || row === null) return undefined;
test/review-comment-bounds.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * The bounds on what one review or comment may carry: more than
3 * MAX_REVIEW_LINES line comments is refused whole, exactly that many lands,
4 * and a comment of nothing but whitespace is refused over the API and the
5 * MCP alike. A refusal writes nothing.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { MAX_REVIEW_LINES, openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { listComments } from "../src/core/issues.ts";
15 import { listReviewComments, listReviews } from "../src/core/reviews.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { promote } from "./helpers/members.ts";
21
22 quietLogs();
23
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let token = "";
28 let pull: { id: string; number: number };
29 let nextId = 1;
30
31 const ok = <T>(
32 r: { ok: true; value: T } | { ok: false; error: unknown },
33 ): T => {
34 if (!r.ok) throw new Error(JSON.stringify(r.error));
35 return r.value;
36 };
37
38 const post = (path: string, payload: unknown) =>
39 fetch(`${servers.url}/api/v1/repos/alice/bounds/${path}`, {
40 method: "POST",
41 headers: {
42 authorization: `Bearer ${token}`,
43 "content-type": "application/json",
44 },
45 body: JSON.stringify(payload),
46 });
47
48 const lines = (n: number) =>
49 Array.from({ length: n }, (_, i) => ({
50 path: "big.txt",
51 line: i + 1,
52 side: "new",
53 body: `note ${i + 1}`,
54 }));
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-review-bounds-"));
58 app = createApp({ dataDir, port: 0 });
59 servers = await serve(app);
60 const mk = async (username: string, first = false) =>
61 ok(
62 await createUser(app.db, {
63 origin: "seed",
64 trusted: true,
65 username,
66 email: `${username}@forge.local`,
67 password: "a-good-passphrase",
68 adminIfFirst: first,
69 }),
70 );
71 const alice = await mk("alice", true);
72 const bob = await mk("bob");
73 promote(app.db, alice, bob);
74 token = createToken(app.db, alice.id, "rw", "rw").token;
75 const repo = ok(
76 await createRepo(app, alice, { name: "bounds", visibility: "public" }),
77 );
78 ok(
79 await commitToBranch(app, alice, repo, {
80 message: "Start",
81 files: [{ path: "README.md", content: "# bounds\n" }],
82 }),
83 );
84 ok(
85 await commitToBranch(app, alice, repo, {
86 branch: "topic",
87 base: "main",
88 message: "Many lines",
89 files: [
90 {
91 path: "big.txt",
92 content: Array.from({ length: 60 }, (_, i) => `row ${i + 1}\n`).join(""),
93 },
94 ],
95 }),
96 );
97 const opened = ok(
98 await openPull(app, alice, repo, {
99 title: "Many lines",
100 body: "",
101 baseBranch: "main",
102 headBranch: "topic",
103 }),
104 );
105 pull = { id: opened.id, number: opened.number };
106 }, 30_000);
107
108 afterAll(async () => {
109 servers.stop();
110 app.close();
111 await rm(dataDir, { recursive: true, force: true });
112 });
113
114 test("a review with more than the cap of line comments is refused and nothing is stored", async () => {
115 const res = await post(`pulls/${pull.number}/review-states`, {
116 state: "commented",
117 comments: lines(MAX_REVIEW_LINES + 1),
118 });
119 expect(res.status).toBe(422);
120 expect(((await res.json()) as { error: string }).error).toBe(
121 "too_many_lines",
122 );
123 expect(listReviews(app.db, pull.id)).toEqual([]);
124 expect(listReviewComments(app.db, pull.id)).toEqual([]);
125 });
126
127 test("exactly the cap of line comments lands", async () => {
128 const res = await post(`pulls/${pull.number}/review-states`, {
129 state: "commented",
130 comments: lines(MAX_REVIEW_LINES),
131 });
132 expect(res.status).toBe(201);
133 expect(listReviews(app.db, pull.id)).toHaveLength(1);
134 expect(listReviewComments(app.db, pull.id)).toHaveLength(MAX_REVIEW_LINES);
135 });
136
137 test("a comment of spaces and newlines is refused over the API and the MCP, and the thread is unchanged", async () => {
138 const before = listComments(app.db, pull.id).length;
139 const blank = " \n\t \n ";
140 const api = await post(`issues/${pull.number}/comments`, { body: blank });
141 expect(api.status).toBe(422);
142 expect(((await api.json()) as { error: string }).error).toBe("empty_body");
143 const onPull = await post(`pulls/${pull.number}/comments`, { body: blank });
144 expect(onPull.status).toBe(422);
145
146 const res = await fetch(`${servers.url}/mcp`, {
147 method: "POST",
148 headers: {
149 "content-type": "application/json",
150 accept: "application/json, text/event-stream",
151 authorization: `Bearer ${token}`,
152 },
153 body: JSON.stringify({
154 jsonrpc: "2.0",
155 id: nextId++,
156 method: "tools/call",
157 params: {
158 name: "say",
159 arguments: { repo: "alice/bounds", number: pull.number, text: blank },
160 },
161 }),
162 });
163 const body = (await res.json()) as any;
164 expect(body.result.isError).toBe(true);
165 expect(listComments(app.db, pull.id)).toHaveLength(before);
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs