e2e.patches.test.ts
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import { existsSync, readFileSync } from 'node:fs';
5import {
6 BASE,
7 DATA_DIR,
8 ADMIN_PASS,
9 setupTestEnv,
10 spawnServer,
11 killServer,
12 login,
13 seedRepo,
14 writeTempFile,
15 gitOutput,
16} from './helpers.ts';
17
18let browser: Browser;
19let server: Awaited<ReturnType<typeof spawnServer>>;
20
21beforeAll(async () => {
22 await setupTestEnv();
23 server = await spawnServer();
24 browser = await chromium.launch();
25
26 // Register alice
27 const regCtx = await browser.newContext();
28 const regPage = await regCtx.newPage();
29 try {
30 await regPage.goto(`${BASE}/register`);
31 await regPage.fill('[name=username]', 'alice');
32 await regPage.fill('[name=password]', 'password123');
33 await regPage.fill('[name=password2]', 'password123');
34 await regPage.click('button[type=submit]');
35 await regPage.waitForURL(BASE + '/');
36 } finally { await regCtx.close(); }
37
38 // Create my-repo
39 const adminCtx = await browser.newContext();
40 const adminPage = await adminCtx.newPage();
41 try {
42 await login(adminPage);
43 await adminPage.goto(`${BASE}/new`);
44 await adminPage.fill('[name=name]', 'my-repo');
45 await adminPage.click('form[action="/new"] button[type=submit]');
46 await adminPage.waitForURL(`${BASE}/my-repo`);
47 } finally { await adminCtx.close(); }
48
49 await seedRepo('my-repo');
50});
51
52afterAll(async () => {
53 await browser.close();
54 await killServer(server);
55});
56
57async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
58 const ctx = await browser.newContext();
59 const page = await ctx.newPage();
60 await login(page, username, password);
61 await page.close();
62 return ctx;
63}
64
65// ─── Patches ──────────────────────────────────────────────────────────────────
66
67describe('patches', () => {
68 let adminCtx: BrowserContext;
69 let cleanPatchUrl: string;
70 let conflictPatchUrl: string;
71 let closePatchUrl: string;
72
73 // Adds a new file — applies cleanly to my-repo
74 const CLEAN_PATCH = [
75 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
76 'From: Test User <test@example.com>',
77 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
78 'Subject: [PATCH] Add patch-test.txt',
79 '',
80 '---',
81 'diff --git a/patch-test.txt b/patch-test.txt',
82 'new file mode 100644',
83 'index 0000000..9daeafb',
84 '--- /dev/null',
85 '+++ b/patch-test.txt',
86 '@@ -0,0 +1 @@',
87 '+patch test content',
88 '',
89 ].join('\n');
90
91 // References non-existent lines in README.md — always conflicts
92 const CONFLICT_PATCH = [
93 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
94 'From: Test User <test@example.com>',
95 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
96 'Subject: [PATCH] Modify README',
97 '',
98 '---',
99 'diff --git a/README.md b/README.md',
100 'index abc1234..def5678 100644',
101 '--- a/README.md',
102 '+++ b/README.md',
103 '@@ -50,3 +50,3 @@',
104 ' nonexistent context line',
105 '-nonexistent old line',
106 '+nonexistent new line',
107 '',
108 ].join('\n');
109
110 // Adds another new file — for testing close flow
111 const CLOSE_PATCH = [
112 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
113 'From: Test User <test@example.com>',
114 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
115 'Subject: [PATCH] Add patch-close.txt',
116 '',
117 '---',
118 'diff --git a/patch-close.txt b/patch-close.txt',
119 'new file mode 100644',
120 'index 0000000..9daeafb',
121 '--- /dev/null',
122 '+++ b/patch-close.txt',
123 '@@ -0,0 +1 @@',
124 '+close test',
125 '',
126 ].join('\n');
127
128 beforeAll(async () => {
129 adminCtx = await loggedInContext();
130 });
131
132 afterAll(async () => { await adminCtx.close(); });
133
134 test('reject file without patch markers', async () => {
135 writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
136 const page = await adminCtx.newPage();
137 try {
138 await page.goto(`${BASE}/my-repo/patches/new`);
139 await page.fill('[name=title]', 'Bad patch');
140 await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
141 await page.click('form[action$="/patches"] button[type=submit]');
142 expect(await page.locator('.form-error').textContent()).toContain('valid patch');
143 } finally { await page.close(); }
144 });
145
146 test('reject patch missing Subject header', async () => {
147 writeTempFile('/tmp/no-subject.patch', [
148 'From: Test User <test@example.com>',
149 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
150 '',
151 '---',
152 'diff --git a/f.txt b/f.txt',
153 'new file mode 100644',
154 '--- /dev/null',
155 '+++ b/f.txt',
156 '@@ -0,0 +1 @@',
157 '+x',
158 '',
159 ].join('\n'));
160 const page = await adminCtx.newPage();
161 try {
162 await page.goto(`${BASE}/my-repo/patches/new`);
163 await page.fill('[name=title]', 'No subject');
164 await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
165 await page.click('form[action$="/patches"] button[type=submit]');
166 expect(await page.locator('.form-error').textContent()).toContain('Subject');
167 } finally { await page.close(); }
168 });
169
170 test('reject patch missing From header', async () => {
171 writeTempFile('/tmp/no-from.patch', [
172 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
173 'Subject: [PATCH] Add f.txt',
174 '',
175 '---',
176 'diff --git a/f.txt b/f.txt',
177 'new file mode 100644',
178 '--- /dev/null',
179 '+++ b/f.txt',
180 '@@ -0,0 +1 @@',
181 '+x',
182 '',
183 ].join('\n'));
184 const page = await adminCtx.newPage();
185 try {
186 await page.goto(`${BASE}/my-repo/patches/new`);
187 await page.fill('[name=title]', 'No from');
188 await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
189 await page.click('form[action$="/patches"] button[type=submit]');
190 expect(await page.locator('.form-error').textContent()).toContain('From');
191 } finally { await page.close(); }
192 });
193
194 test('reject patch missing Date header', async () => {
195 writeTempFile('/tmp/no-date.patch', [
196 'From: Test User <test@example.com>',
197 'Subject: [PATCH] Add f.txt',
198 '',
199 '---',
200 'diff --git a/f.txt b/f.txt',
201 'new file mode 100644',
202 '--- /dev/null',
203 '+++ b/f.txt',
204 '@@ -0,0 +1 @@',
205 '+x',
206 '',
207 ].join('\n'));
208 const page = await adminCtx.newPage();
209 try {
210 await page.goto(`${BASE}/my-repo/patches/new`);
211 await page.fill('[name=title]', 'No date');
212 await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
213 await page.click('form[action$="/patches"] button[type=submit]');
214 expect(await page.locator('.form-error').textContent()).toContain('Date');
215 } finally { await page.close(); }
216 });
217
218 test('upload clean patch', async () => {
219 writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
220 const page = await adminCtx.newPage();
221 try {
222 await page.goto(`${BASE}/my-repo/patches/new`);
223 await page.fill('[name=title]', 'Add patch-test.txt');
224 await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
225 await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
226 await page.click('form[action$="/patches"] button[type=submit]');
227 await page.waitForURL(/\/my-repo\/patches\/\d+/);
228 cleanPatchUrl = page.url();
229 expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
230 } finally { await page.close(); }
231 });
232
233 test('changes tab shows commit metadata card', async () => {
234 const page = await adminCtx.newPage();
235 try {
236 await page.goto(cleanPatchUrl + '?tab=changes');
237 expect(await page.locator('.commit-card').isVisible()).toBe(true);
238 expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
239 expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
240 expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
241 expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
242 } finally { await page.close(); }
243 });
244
245 test('patch description renders markdown', async () => {
246 const page = await adminCtx.newPage();
247 try {
248 await page.goto(cleanPatchUrl);
249 expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
250 } finally { await page.close(); }
251 });
252
253 test('clean patch shows apply-clean status immediately', async () => {
254 const page = await adminCtx.newPage();
255 try {
256 await page.goto(cleanPatchUrl);
257 expect(await page.locator('.apply-result').isVisible()).toBe(true);
258 expect(await page.locator('.apply-clean').isVisible()).toBe(true);
259 } finally { await page.close(); }
260 });
261
262 test('merge button appears for clean patch', async () => {
263 const page = await adminCtx.newPage();
264 try {
265 await page.goto(cleanPatchUrl);
266 expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
267 } finally { await page.close(); }
268 });
269
270 test('patch diff is displayed with highlighted table', async () => {
271 const page = await adminCtx.newPage();
272 try {
273 await page.goto(cleanPatchUrl + '?tab=changes');
274 expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
275 expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
276 } finally { await page.close(); }
277 });
278
279 test('patch appears in open list', async () => {
280 const page = await adminCtx.newPage();
281 try {
282 await page.goto(`${BASE}/my-repo/patches`);
283 const titles = await page.locator('.issue-title').allTextContents();
284 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
285 } finally { await page.close(); }
286 });
287
288 test('unauthenticated user is redirected to login from patch upload', async () => {
289 const ctx = await browser.newContext();
290 const page = await ctx.newPage();
291 try {
292 await page.goto(`${BASE}/my-repo/patches/new`);
293 expect(page.url()).toContain('/login');
294 } finally { await ctx.close(); }
295 });
296
297 test('upload conflict patch', async () => {
298 writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
299 const page = await adminCtx.newPage();
300 try {
301 await page.goto(`${BASE}/my-repo/patches/new`);
302 await page.fill('[name=title]', 'Conflict patch');
303 await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
304 await page.click('form[action$="/patches"] button[type=submit]');
305 await page.waitForURL(/\/my-repo\/patches\/\d+/);
306 conflictPatchUrl = page.url();
307 } finally { await page.close(); }
308 });
309
310 test('conflict patch shows apply-conflict status', async () => {
311 const page = await adminCtx.newPage();
312 try {
313 await page.goto(conflictPatchUrl);
314 expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
315 } finally { await page.close(); }
316 });
317
318 test('merge button absent for conflict patch', async () => {
319 const page = await adminCtx.newPage();
320 try {
321 await page.goto(conflictPatchUrl);
322 expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
323 } finally { await page.close(); }
324 });
325
326 test('merge clean patch changes status to merged', async () => {
327 const page = await adminCtx.newPage();
328 try {
329 await page.goto(cleanPatchUrl);
330 await page.click('form[action*="/merge"] button');
331 await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
332 expect(await page.locator('.patch-badge').textContent()).toBe('merged');
333 } finally { await page.close(); }
334 });
335
336 test('merge uses patch From header as git author', async () => {
337 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
338 const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
339 const authorEmail = gitOutput(['log', '-1', '--format=%aE'], repoPath);
340 const subject = gitOutput(['log', '-1', '--format=%s'], repoPath);
341 expect(authorName).toBe('Test User');
342 expect(authorEmail).toBe('test@example.com');
343 expect(subject).toBe('Add patch-test.txt');
344 });
345
346 test('merged patch appears in merged list', async () => {
347 const page = await adminCtx.newPage();
348 try {
349 await page.goto(`${BASE}/my-repo/patches?status=merged`);
350 const titles = await page.locator('.issue-title').allTextContents();
351 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
352 } finally { await page.close(); }
353 });
354
355 test('upload and close a patch', async () => {
356 writeTempFile('/tmp/close.patch', CLOSE_PATCH);
357 const page = await adminCtx.newPage();
358 try {
359 await page.goto(`${BASE}/my-repo/patches/new`);
360 await page.fill('[name=title]', 'Close me');
361 await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
362 await page.click('form[action$="/patches"] button[type=submit]');
363 await page.waitForURL(/\/my-repo\/patches\/\d+/);
364 closePatchUrl = page.url();
365 await page.click('form[action*="/close"] button');
366 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
367 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
368 } finally { await page.close(); }
369 });
370
371 test('closed patch appears in closed list', async () => {
372 const page = await adminCtx.newPage();
373 try {
374 await page.goto(`${BASE}/my-repo/patches?status=closed`);
375 const titles = await page.locator('.issue-title').allTextContents();
376 expect(titles.some(t => t.includes('Close me'))).toBe(true);
377 } finally { await page.close(); }
378 });
379
380 test('closed patch can be reopened', async () => {
381 const page = await adminCtx.newPage();
382 try {
383 await page.goto(closePatchUrl);
384 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
385 await page.click('form[action*="/close"] button');
386 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
387 expect(await page.locator('.patch-badge').textContent()).toBe('open');
388 } finally { await page.close(); }
389 });
390
391 test('patch title and description can be edited', async () => {
392 const page = await adminCtx.newPage();
393 try {
394 await page.goto(conflictPatchUrl);
395 // Edit title via title form
396 await page.click('.title-edit-open');
397 await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
398 await page.click('.title-edit-form-area [type=submit]');
399 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
400 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
401 // Edit description via inline form
402 await page.click('.timeline-author .inline-edit-details summary');
403 await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
404 await page.click('.inline-edit-form-area [type=submit]');
405 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
406 } finally { await page.close(); }
407 });
408
409 test('patch comment: add and edit', async () => {
410 const page = await adminCtx.newPage();
411 try {
412 await page.goto(conflictPatchUrl);
413 await page.fill('[name=body]', 'My patch comment');
414 await page.click('form[action$="/comments"] button[type=submit]');
415 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
416 expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
417
418 // Edit the comment — scope to the timeline-item containing the comment text
419 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
420 await commentItem.locator('.inline-edit-details summary').click();
421 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
422 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
423 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
424 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
425 } finally { await page.close(); }
426 });
427
428 test('patch reaction on description', async () => {
429 const page = await adminCtx.newPage();
430 try {
431 await page.goto(conflictPatchUrl);
432 // Open reaction picker on the first timeline-item (description)
433 await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
434 await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
435 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
436 expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
437 } finally { await page.close(); }
438 });
439
440 test('admin can delete a patch', async () => {
441 const page = await adminCtx.newPage();
442 try {
443 const patchNum = conflictPatchUrl.split('/patches/')[1];
444 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
445 maxRedirects: 0,
446 });
447 expect(resp.status()).toBe(302);
448 expect(resp.headers()['location']).toContain('/patches');
449 // Patch should no longer be accessible
450 const checkResp = await page.request.get(conflictPatchUrl);
451 expect(checkResp.status()).toBe(404);
452 } finally { await page.close(); }
453 });
454
455 // ── Patch file re-upload & version protection ──────────────────────────────
456
457 let uploadTestPatchUrl: string;
458
459 // Adds upload-test.txt — applies cleanly to my-repo
460 const UPLOAD_TEST_PATCH = [
461 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
462 'From: Original Author <original@example.com>',
463 'Date: Wed, 03 Jan 2024 10:00:00 +0000',
464 'Subject: [PATCH] Add upload-test.txt',
465 '',
466 '---',
467 'diff --git a/upload-test.txt b/upload-test.txt',
468 'new file mode 100644',
469 'index 0000000..9daeafb',
470 '--- /dev/null',
471 '+++ b/upload-test.txt',
472 '@@ -0,0 +1 @@',
473 '+upload test',
474 '',
475 ].join('\n');
476
477 // Replacement: different author, same diff target
478 const REPLACEMENT_PATCH = [
479 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
480 'From: Replaced Author <replaced@example.com>',
481 'Date: Thu, 04 Jan 2024 10:00:00 +0000',
482 'Subject: [PATCH] Add upload-test.txt (v2)',
483 '',
484 '---',
485 'diff --git a/upload-test.txt b/upload-test.txt',
486 'new file mode 100644',
487 'index 0000000..9daeafb',
488 '--- /dev/null',
489 '+++ b/upload-test.txt',
490 '@@ -0,0 +1 @@',
491 '+upload test v2',
492 '',
493 ].join('\n');
494
495 test('create patch for re-upload tests', async () => {
496 writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
497 const page = await adminCtx.newPage();
498 try {
499 await page.goto(`${BASE}/my-repo/patches/new`);
500 await page.fill('[name=title]', 'Upload test patch');
501 await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
502 await page.click('form[action$="/patches"] button[type=submit]');
503 await page.waitForURL(/\/my-repo\/patches\/\d+/);
504 uploadTestPatchUrl = page.url();
505 } finally { await page.close(); }
506 });
507
508 test('upload patch file button is visible for admin on open patch', async () => {
509 const page = await adminCtx.newPage();
510 try {
511 await page.goto(uploadTestPatchUrl);
512 expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
513 } finally { await page.close(); }
514 });
515
516 test('non-author non-admin cannot upload patch file', async () => {
517 const aliceCtx = await loggedInContext('alice', 'password123');
518 const page = await aliceCtx.newPage();
519 try {
520 const patchNum = uploadTestPatchUrl.split('/patches/')[1];
521 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
522 multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
523 maxRedirects: 0,
524 });
525 expect(resp.status()).toBe(403);
526 } finally { await aliceCtx.close(); }
527 });
528
529 test('upload button hidden for non-author non-admin', async () => {
530 const aliceCtx = await loggedInContext('alice', 'password123');
531 const page = await aliceCtx.newPage();
532 try {
533 await page.goto(uploadTestPatchUrl);
534 expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
535 } finally { await aliceCtx.close(); }
536 });
537
538 test('admin can upload replacement patch file', async () => {
539 writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
540 const page = await adminCtx.newPage();
541 try {
542 await page.goto(uploadTestPatchUrl);
543 await page.locator('details:has([name=patch_file]) summary').click();
544 await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
545 await page.locator('details:has([name=patch_file]) button[type=submit]').click();
546 await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
547 } finally { await page.close(); }
548 });
549
550 test('merge fails when version token is stale', async () => {
551 // Patch that adds stale-version.txt — applies cleanly
552 const STALE_PATCH = [
553 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
554 'From: Test User <test@example.com>',
555 'Date: Fri, 05 Jan 2024 10:00:00 +0000',
556 'Subject: [PATCH] Add stale-version.txt',
557 '',
558 '---',
559 'diff --git a/stale-version.txt b/stale-version.txt',
560 'new file mode 100644',
561 'index 0000000..9daeafb',
562 '--- /dev/null',
563 '+++ b/stale-version.txt',
564 '@@ -0,0 +1 @@',
565 '+stale',
566 '',
567 ].join('\n');
568 const STALE_PATCH_V2 = STALE_PATCH
569 .replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
570 .replace('+stale', '+stale v2');
571
572 writeTempFile('/tmp/stale.patch', STALE_PATCH);
573 const page = await adminCtx.newPage();
574 try {
575 // Create the patch
576 await page.goto(`${BASE}/my-repo/patches/new`);
577 await page.fill('[name=title]', 'Stale version test');
578 await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
579 await page.click('form[action$="/patches"] button[type=submit]');
580 await page.waitForURL(/\/my-repo\/patches\/\d+/);
581 const stalePatchUrl = page.url();
582 const patchNum = stalePatchUrl.split('/patches/')[1];
583
584 // Capture the version the admin sees on the page
585 const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
586
587 // Author uploads a new patch file (simulated by admin here), bumping the version
588 writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
589 await page.locator('details:has([name=patch_file]) summary').click();
590 await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
591 await page.locator('details:has([name=patch_file]) button[type=submit]').click();
592 await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
593
594 // Admin tries to merge with the stale version — should be rejected
595 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
596 form: { version: staleVersion },
597 maxRedirects: 0,
598 });
599 expect(resp.status()).toBe(409);
600 expect(await resp.text()).toContain('updated');
601
602 // Patch status must still be open
603 const checkResp = await page.request.get(stalePatchUrl);
604 expect(checkResp.status()).toBe(200);
605 expect(await checkResp.text()).toContain('open');
606 } finally { await page.close(); }
607 });
608
609 test('merge succeeds with current version token after replacement upload', async () => {
610 const page = await adminCtx.newPage();
611 try {
612 await page.goto(uploadTestPatchUrl);
613 await page.click('form[action*="/merge"] button');
614 await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
615 expect(await page.locator('.patch-badge').textContent()).toBe('merged');
616 // Merged commit should carry the replacement patch's author
617 const repoPath = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
618 const authorName = gitOutput(['log', '-1', '--format=%aN'], repoPath);
619 expect(authorName).toBe('Replaced Author');
620 } finally { await page.close(); }
621 });
622
623 test('upload patch file button hidden on merged patch', async () => {
624 const page = await adminCtx.newPage();
625 try {
626 await page.goto(uploadTestPatchUrl);
627 expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
628 } finally { await page.close(); }
629 });
630
631 test('POST to upload on merged patch returns 400', async () => {
632 const patchNum = uploadTestPatchUrl.split('/patches/')[1];
633 const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
634 multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
635 maxRedirects: 0,
636 });
637 expect(resp.status()).toBe(400);
638 });
639});
640
641// ─── Commit signing ───────────────────────────────────────────────────────────
642// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
643
644describe('commit signing', () => {
645 let adminCtx: BrowserContext;
646
647 beforeAll(async () => { adminCtx = await loggedInContext(); });
648 afterAll(async () => { await adminCtx.close(); });
649
650 test('allowed_signers file is generated at startup', () => {
651 const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
652 expect(existsSync(allowedSignersPath)).toBe(true);
653 const content = readFileSync(allowedSignersPath, 'utf8');
654 expect(content).toContain('namespaces="git"');
655 expect(content).toContain('ssh-ed25519');
656 });
657
658 test('merged commit has a gpgsig header', async () => {
659 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
660 // Find the patch commit specifically by subject
661 const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
662 expect(hash).toBeTruthy();
663 const obj = gitOutput(['cat-file', '-p', hash], repoDir);
664 expect(obj).toContain('gpgsig');
665 });
666
667 test('unsigned commits have no gpgsig header', async () => {
668 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
669 // Initial commit was created by seedRepo (plain git commit, not hearthforge)
670 const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
671 expect(hash).toBeTruthy();
672 const obj = gitOutput(['cat-file', '-p', hash], repoDir);
673 expect(obj).not.toContain('gpgsig');
674 });
675
676 test('commit log shows verified badge on signed commit', async () => {
677 const page = await adminCtx.newPage();
678 try {
679 await page.goto(`${BASE}/my-repo/commits/main`);
680 // Find the commit-item for the merged patch by subject text
681 const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
682 expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
683 } finally { await page.close(); }
684 });
685
686 test('commit log shows no sig badge on unsigned commit', async () => {
687 const page = await adminCtx.newPage();
688 try {
689 await page.goto(`${BASE}/my-repo/commits/main`);
690 // Initial commit was not signed via hearthforge
691 const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
692 expect(await initialItem.locator('.sig-badge').count()).toBe(0);
693 } finally { await page.close(); }
694 });
695
696 test('commit detail shows verified signature row for signed commit', async () => {
697 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
698 const hash = gitOutput(['log', '--format=%H', '--grep=Add patch-test.txt', '-1'], repoDir);
699 const page = await adminCtx.newPage();
700 try {
701 await page.goto(`${BASE}/my-repo/commit/${hash}`);
702 const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
703 expect(await sigRow.isVisible()).toBe(true);
704 expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
705 } finally { await page.close(); }
706 });
707
708 test('commit detail shows no signature row for unsigned commit', async () => {
709 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
710 const hash = gitOutput(['log', '--format=%H', '--grep=Initial commit', '-1'], repoDir);
711 const page = await adminCtx.newPage();
712 try {
713 await page.goto(`${BASE}/my-repo/commit/${hash}`);
714 const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
715 expect(await sigRow.count()).toBe(0);
716 } finally { await page.close(); }
717 });
718});
719