e2e.patches.test.ts
| 1 | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; |
| 2 | import { chromium } from 'playwright'; |
| 3 | import type { Browser, BrowserContext } from 'playwright'; |
| 4 | import { existsSync, readFileSync } from 'node:fs'; |
| 5 | import { |
| 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 | |
| 18 | let browser: Browser; |
| 19 | let server: Awaited<ReturnType<typeof spawnServer>>; |
| 20 | |
| 21 | beforeAll(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 | |
| 52 | afterAll(async () => { |
| 53 | await browser.close(); |
| 54 | await killServer(server); |
| 55 | }); |
| 56 | |
| 57 | async 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 | |
| 67 | describe('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 | |
| 644 | describe('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 |