Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

comment-moderation.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

comment-moderation.test.tsBlame374 lines · 1 contributor
cb5a796Claude1/**
2 * Comment moderation tests — the anti-impersonation gate.
3 *
4 * Two halves:
5 * 1. Pure-decision tests (`shouldRequireApproval`) — set up a repo
6 * with a fixed owner + a couple of commenters and assert each
7 * branch of the decision matrix (owner / write collab / read-only
8 * stranger / trusted / banned / thread-author).
9 * 2. End-to-end POST → render flow — submit a comment as a stranger
10 * via `app.request`, confirm it lands as 'pending', isn't shown in
11 * the JSON API list, and that the approve flow promotes it.
12 *
13 * Every DB-touching test sits behind `describe.skipIf(!HAS_DB)` so the
14 * suite stays green on machines without Postgres (mirroring the
15 * established pattern across the repo's other test files).
16 */
17
18import { describe, it, expect } from "bun:test";
19
20const HAS_DB = Boolean(process.env.DATABASE_URL);
21
22describe.skipIf(!HAS_DB)("comment-moderation — shouldRequireApproval", () => {
23 it("returns false for the repo owner", async () => {
24 const { shouldRequireApproval } = await import(
25 "../lib/comment-moderation"
26 );
27 const fx = await seed();
28 const r = await shouldRequireApproval({
29 commenterUserId: fx.ownerId,
30 repositoryId: fx.repoId,
31 kind: "issue",
32 threadId: fx.issueId,
33 });
34 expect(r.requireApproval).toBe(false);
35 });
36
37 it("returns false for a write collaborator", async () => {
38 const { shouldRequireApproval } = await import(
39 "../lib/comment-moderation"
40 );
41 const fx = await seed();
42 await addCollaborator(fx.repoId, fx.writerId, "write");
43 const r = await shouldRequireApproval({
44 commenterUserId: fx.writerId,
45 repositoryId: fx.repoId,
46 kind: "issue",
47 threadId: fx.issueId,
48 });
49 expect(r.requireApproval).toBe(false);
50 });
51
52 it("returns true for a read-only stranger on a public repo", async () => {
53 const { shouldRequireApproval } = await import(
54 "../lib/comment-moderation"
55 );
56 const fx = await seed();
57 const r = await shouldRequireApproval({
58 commenterUserId: fx.strangerId,
59 repositoryId: fx.repoId,
60 kind: "issue",
61 threadId: fx.issueId,
62 });
63 expect(r.requireApproval).toBe(true);
64 expect(r.autoReject).toBe(false);
65 });
66
67 it("returns false when the commenter has a 'trusted' trust row", async () => {
68 const { shouldRequireApproval } = await import(
69 "../lib/comment-moderation"
70 );
71 const { db } = await import("../db");
72 const { repoCommenterTrust } = await import("../db/schema");
73 const fx = await seed();
74 await db.insert(repoCommenterTrust).values({
75 repositoryId: fx.repoId,
76 commenterUserId: fx.strangerId,
77 status: "trusted",
78 grantedByUserId: fx.ownerId,
79 });
80 const r = await shouldRequireApproval({
81 commenterUserId: fx.strangerId,
82 repositoryId: fx.repoId,
83 kind: "issue",
84 threadId: fx.issueId,
85 });
86 expect(r.requireApproval).toBe(false);
87 });
88
89 it("returns false when the commenter is the original issue author", async () => {
90 const { shouldRequireApproval } = await import(
91 "../lib/comment-moderation"
92 );
93 const { db } = await import("../db");
94 const { issues } = await import("../db/schema");
95 const fx = await seed();
96 // Open a SECOND issue authored by the stranger themselves so they
97 // are the thread author (the seed issue is opened by `ownerId`).
98 const [strangerIssue] = await db
99 .insert(issues)
100 .values({
101 repositoryId: fx.repoId,
102 authorId: fx.strangerId,
103 title: "stranger-issue",
104 })
105 .returning({ id: issues.id });
106 const r = await shouldRequireApproval({
107 commenterUserId: fx.strangerId,
108 repositoryId: fx.repoId,
109 kind: "issue",
110 threadId: strangerIssue!.id,
111 });
112 expect(r.requireApproval).toBe(false);
113 expect(r.reason).toContain("opened this issue");
114 });
115
116 it("auto-rejects when the commenter is banned", async () => {
117 const { shouldRequireApproval } = await import(
118 "../lib/comment-moderation"
119 );
120 const { db } = await import("../db");
121 const { repoCommenterTrust } = await import("../db/schema");
122 const fx = await seed();
123 await db.insert(repoCommenterTrust).values({
124 repositoryId: fx.repoId,
125 commenterUserId: fx.strangerId,
126 status: "banned",
127 grantedByUserId: fx.ownerId,
128 });
129 const r = await shouldRequireApproval({
130 commenterUserId: fx.strangerId,
131 repositoryId: fx.repoId,
132 kind: "issue",
133 threadId: fx.issueId,
134 });
135 expect(r.requireApproval).toBe(true);
136 expect(r.autoReject).toBe(true);
137 });
138});
139
140describe.skipIf(!HAS_DB)("comment-moderation — POST + render flow", () => {
141 it("inserts a stranger's comment as 'pending' and hides it from the public API", async () => {
142 const app = (await import("../app")).default;
143 const { db } = await import("../db");
144 const { issueComments, sessions } = await import("../db/schema");
145 const { eq } = await import("drizzle-orm");
146 const { randomBytes } = await import("crypto");
147
148 const fx = await seed();
149 // Mint a stranger session.
150 const token = randomBytes(32).toString("hex");
151 await db.insert(sessions).values({
152 userId: fx.strangerId,
153 token,
154 expiresAt: new Date(Date.now() + 60_000),
155 });
156
157 const res = await app.request(
158 `/${fx.ownerUsername}/${fx.repoName}/issues/${fx.issueNumber}/comment`,
159 {
160 method: "POST",
161 headers: {
162 cookie: `session=${token}`,
163 "content-type": "application/x-www-form-urlencoded",
164 },
165 body: new URLSearchParams({ body: "Hi from a stranger!" }),
166 }
167 );
168 // Hono's c.redirect → 302
169 expect([200, 302]).toContain(res.status);
170
171 const inserted = await db
172 .select()
173 .from(issueComments)
174 .where(eq(issueComments.authorId, fx.strangerId));
175 expect(inserted.length).toBe(1);
176 expect(inserted[0]!.moderationStatus).toBe("pending");
177
178 // Public API hides pending.
179 const apiRes = await app.request(
180 `/api/v2/repos/${fx.ownerUsername}/${fx.repoName}/issues/${fx.issueNumber}`
181 );
182 if (apiRes.status === 200) {
183 const json = (await apiRes.json()) as { comments: unknown[] };
184 expect(Array.isArray(json.comments)).toBe(true);
185 // The stranger's comment must not leak.
186 const bodies = (json.comments as Array<{ body: string }>).map(
187 (c) => c.body
188 );
189 expect(bodies.includes("Hi from a stranger!")).toBe(false);
190 }
191 });
192
193 it("approve flow flips status to 'approved' and notifies the author", async () => {
194 const { approveComment } = await import("../lib/comment-moderation");
195 const { db } = await import("../db");
196 const { issueComments, notifications } = await import("../db/schema");
197 const { eq, and } = await import("drizzle-orm");
198
199 const fx = await seed();
200 // Seed a pending comment from the stranger directly.
201 const [c] = await db
202 .insert(issueComments)
203 .values({
204 issueId: fx.issueId,
205 authorId: fx.strangerId,
206 body: "Approve me please",
207 moderationStatus: "pending",
208 })
209 .returning({ id: issueComments.id });
210
211 const res = await approveComment({
212 commentId: c!.id,
213 kind: "issue",
214 moderatorUserId: fx.ownerId,
215 });
216 expect(res.ok).toBe(true);
217
218 const [after] = await db
219 .select({ status: issueComments.moderationStatus })
220 .from(issueComments)
221 .where(eq(issueComments.id, c!.id));
222 expect(after!.status).toBe("approved");
223
224 // Notification fired.
225 const notif = await db
226 .select()
227 .from(notifications)
228 .where(
229 and(
230 eq(notifications.userId, fx.strangerId),
231 eq(notifications.kind, "comment.approved")
232 )
233 );
234 expect(notif.length).toBeGreaterThanOrEqual(1);
235 });
236
237 it("markAsSpam flow inserts a 'banned' trust row and auto-rejects next comment", async () => {
238 const {
239 markAsSpam,
240 decideInitialStatus,
241 } = await import("../lib/comment-moderation");
242 const { db } = await import("../db");
243 const { issueComments, repoCommenterTrust } = await import("../db/schema");
244 const { eq, and } = await import("drizzle-orm");
245
246 const fx = await seed();
247 const [c] = await db
248 .insert(issueComments)
249 .values({
250 issueId: fx.issueId,
251 authorId: fx.strangerId,
252 body: "Spammy stuff",
253 moderationStatus: "pending",
254 })
255 .returning({ id: issueComments.id });
256
257 const res = await markAsSpam({
258 commentId: c!.id,
259 kind: "issue",
260 moderatorUserId: fx.ownerId,
261 });
262 expect(res.ok).toBe(true);
263
264 // Banned trust row exists.
265 const trust = await db
266 .select({ status: repoCommenterTrust.status })
267 .from(repoCommenterTrust)
268 .where(
269 and(
270 eq(repoCommenterTrust.repositoryId, fx.repoId),
271 eq(repoCommenterTrust.commenterUserId, fx.strangerId)
272 )
273 );
274 expect(trust.length).toBe(1);
275 expect(trust[0]!.status).toBe("banned");
276
277 // Next comment auto-rejects.
278 const decision = await decideInitialStatus({
279 commenterUserId: fx.strangerId,
280 repositoryId: fx.repoId,
281 kind: "issue",
282 threadId: fx.issueId,
283 });
284 expect(decision.status).toBe("rejected");
285 });
286});
287
288// ─── seed helpers ─────────────────────────────────────────────────────
289
290async function seed(): Promise<{
291 ownerId: string;
292 ownerUsername: string;
293 writerId: string;
294 strangerId: string;
295 repoId: string;
296 repoName: string;
297 issueId: string;
298 issueNumber: number;
299}> {
300 const { db } = await import("../db");
301 const { users, repositories, issues } = await import("../db/schema");
302 const { randomBytes } = await import("crypto");
303 const tag = randomBytes(4).toString("hex");
304
305 const [owner] = await db
306 .insert(users)
307 .values({
308 username: `modq_owner_${tag}`,
309 email: `modq_owner_${tag}@test.local`,
310 passwordHash: "x",
311 })
312 .returning({ id: users.id, username: users.username });
313 const [writer] = await db
314 .insert(users)
315 .values({
316 username: `modq_writer_${tag}`,
317 email: `modq_writer_${tag}@test.local`,
318 passwordHash: "x",
319 })
320 .returning({ id: users.id });
321 const [stranger] = await db
322 .insert(users)
323 .values({
324 username: `modq_stranger_${tag}`,
325 email: `modq_stranger_${tag}@test.local`,
326 passwordHash: "x",
327 })
328 .returning({ id: users.id });
329 const [repo] = await db
330 .insert(repositories)
331 .values({
332 ownerId: owner!.id,
333 name: `modq-repo-${tag}`,
334 diskPath: `/tmp/modq/${tag}`,
335 defaultBranch: "main",
336 isPrivate: false,
337 })
338 .returning({ id: repositories.id, name: repositories.name });
339 const [issue] = await db
340 .insert(issues)
341 .values({
342 repositoryId: repo!.id,
343 authorId: owner!.id,
344 title: "Seed issue for moderation tests",
345 })
346 .returning({ id: issues.id, number: issues.number });
347
348 return {
349 ownerId: owner!.id,
350 ownerUsername: owner!.username,
351 writerId: writer!.id,
352 strangerId: stranger!.id,
353 repoId: repo!.id,
354 repoName: repo!.name,
355 issueId: issue!.id,
356 issueNumber: issue!.number,
357 };
358}
359
360async function addCollaborator(
361 repoId: string,
362 userId: string,
363 role: "read" | "write" | "admin"
364): Promise<void> {
365 const { db } = await import("../db");
366 const { repoCollaborators } = await import("../db/schema");
367 await db.insert(repoCollaborators).values({
368 repositoryId: repoId,
369 userId,
370 role,
371 invitedBy: userId,
372 acceptedAt: new Date(),
373 });
374}