allow patch updates
MREADME.md
| @@ -34,13 +34,14 @@ bun run start # http://localhost:3000, SSH on port 2222 | |||
|---|---|---|---|
| 34 | 34 | ||
| 35 | 35 | Default admin credentials: `admin` / `changeme` | |
| 36 | 36 | ||
| 37 | - | ## Manual repository creation | |
| 37 | + | ## Manual repository interactions | |
| 38 | 38 | ||
| 39 | - | Repositories can bei either created through the UI, or existing ones can be copied manually to the `data/repos` directory. | |
| 39 | + | Existing Repositories can be copied manually to the `data/repos` directory. | |
| 40 | 40 | Non-bare repos are automatically converted to bare repos on startup, discarding uncomitted changes and worktrees. | |
| 41 | + | Pushing directly to the repositories (i.e. not through the bundled http/ssh endpoints) should generally works as well. | |
| 41 | 42 | ||
| 42 | 43 | ### Docker / Podman | |
| 43 | - | Container images are provided. | |
| 44 | + | Container image and compose file are provided. | |
| 44 | 45 | ```bash | |
| 45 | 46 | docker compose up # or | |
| 46 | 47 | podman compose up | |
| @@ -88,13 +89,11 @@ Pushing is also supported for the admin. | |||
|---|---|---|---|
| 88 | 89 | bun run dev # watch mode | |
| 89 | 90 | bun run lint # Biome lint | |
| 90 | 91 | bun run format # Biome format | |
| 91 | - | bun run test # Playwright E2E tests (don't use bun test, it doesn't respect the timeout) | |
| 92 | + | bun run test # Run E2E and unit tests (don't use bun test, it doesn't respect the timeout) | |
| 92 | 93 | ``` | |
| 93 | 94 | ||
| 94 | 95 | ## Roadmap | |
| 95 | 96 | - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation | |
| 96 | 97 | - Issue labels | |
| 97 | 98 | - redirect image urls in readme | |
| 98 | - | - registration queue | |
| 99 | - | - edit patches | |
| 100 | - | - show ^M in diffs | |
| 99 | + | - registration queue | |
Mpackage.json
| @@ -7,7 +7,7 @@ | |||
|---|---|---|---|
| 7 | 7 | "css:build": "bun scripts/build-css.ts", | |
| 8 | 8 | "db:init": "bun run src/db/init.ts", | |
| 9 | 9 | "db:seed": "bun run src/db/seed.ts", | |
| 10 | - | "test": "bun test tests/highlight.test.ts && bun test tests/e2e.test.ts --timeout 60000", | |
| 10 | + | "test": "bun test --timeout 60000", | |
| 11 | 11 | "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm", | |
| 12 | 12 | "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js", | |
| 13 | 13 | "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build", | |
Msrc/db/index.ts
| @@ -86,6 +86,7 @@ interface PatchTable { | |||
|---|---|---|---|
| 86 | 86 | created_at: string; | |
| 87 | 87 | updated_at: string; | |
| 88 | 88 | edited_at: string | null; | |
| 89 | + | version: string; | |
| 89 | 90 | } | |
| 90 | 91 | ||
| 91 | 92 | interface PatchCommentTable { | |
| @@ -168,6 +169,19 @@ const sqlite = new BunDatabase(DB_PATH); | |||
|---|---|---|---|
| 168 | 169 | sqlite.run("PRAGMA journal_mode=WAL"); | |
| 169 | 170 | sqlite.run("PRAGMA foreign_keys=ON"); | |
| 170 | 171 | ||
| 172 | + | // Migration: add version column if missing, then populate any empty values | |
| 173 | + | const patchCols = sqlite | |
| 174 | + | .query<{ name: string }, []>("PRAGMA table_info(patches)") | |
| 175 | + | .all(); | |
| 176 | + | if (!patchCols.some((c) => c.name === "version")) { | |
| 177 | + | sqlite.run( | |
| 178 | + | "ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''", | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | sqlite.run( | |
| 182 | + | "UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''", | |
| 183 | + | ); | |
| 184 | + | ||
| 171 | 185 | export const db = new Kysely<Database>({ | |
| 172 | 186 | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 173 | 187 | }); | |
Msrc/db/schema.sql
| @@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS patches ( | |||
|---|---|---|---|
| 84 | 84 | created_at TEXT NOT NULL, | |
| 85 | 85 | updated_at TEXT NOT NULL, | |
| 86 | 86 | edited_at TEXT, | |
| 87 | + | version TEXT NOT NULL DEFAULT '', | |
| 87 | 88 | UNIQUE(repo_id, number) | |
| 88 | 89 | ); | |
| 89 | 90 | ||
Msrc/routes/patches.tsx
| @@ -266,6 +266,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 266 | 266 | author_email: uploadMeta.email, | |
| 267 | 267 | created_at: now, | |
| 268 | 268 | updated_at: now, | |
| 269 | + | version: crypto.randomUUID(), | |
| 269 | 270 | }) | |
| 270 | 271 | .returning("id") | |
| 271 | 272 | .executeTakeFirstOrThrow(); | |
| @@ -313,6 +314,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 313 | 314 | "patches.created_at", | |
| 314 | 315 | "patches.updated_at", | |
| 315 | 316 | "patches.edited_at", | |
| 317 | + | "patches.version", | |
| 316 | 318 | "users.username as author_username", | |
| 317 | 319 | "users.avatar_version as author_avatar_version", | |
| 318 | 320 | ]) | |
| @@ -416,60 +418,159 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 416 | 418 | }, | |
| 417 | 419 | ) | |
| 418 | 420 | ||
| 419 | - | .post("/:repo/patches/:number/merge", async ({ params, cookie }) => { | |
| 420 | - | const user = await resolveSession(cookie.session.value); | |
| 421 | - | const deny = requireAdmin(user); | |
| 422 | - | if (deny) return deny; | |
| 421 | + | .post( | |
| 422 | + | "/:repo/patches/:number/merge", | |
| 423 | + | async ({ params, body, cookie }) => { | |
| 424 | + | const user = await resolveSession(cookie.session.value); | |
| 425 | + | const deny = requireAdmin(user); | |
| 426 | + | if (deny) return deny; | |
| 423 | 427 | ||
| 424 | - | const repo = await getRepo(params.repo, true); | |
| 425 | - | if (!repo) return new Response("Not found", { status: 404 }); | |
| 428 | + | const repo = await getRepo(params.repo, true); | |
| 429 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 426 | 430 | ||
| 427 | - | const patchNum = parseInt(params.number, 10); | |
| 428 | - | const patch = await db | |
| 429 | - | .selectFrom("patches") | |
| 430 | - | .select(["id", "patch_content", "status"]) | |
| 431 | - | .where("repo_id", "=", repo.id) | |
| 432 | - | .where("number", "=", patchNum) | |
| 433 | - | .executeTakeFirst(); | |
| 434 | - | if (!patch) return new Response("Not found", { status: 404 }); | |
| 431 | + | const patchNum = parseInt(params.number, 10); | |
| 432 | + | const patch = await db | |
| 433 | + | .selectFrom("patches") | |
| 434 | + | .select(["id", "patch_content", "status", "version"]) | |
| 435 | + | .where("repo_id", "=", repo.id) | |
| 436 | + | .where("number", "=", patchNum) | |
| 437 | + | .executeTakeFirst(); | |
| 438 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 435 | 439 | ||
| 436 | - | // Atomically claim the merge slot before the slow git operation to | |
| 437 | - | // prevent two concurrent requests from both applying the same patch. | |
| 438 | - | const claimed = await db | |
| 439 | - | .updateTable("patches") | |
| 440 | - | .set({ status: "merged", updated_at: new Date().toISOString() }) | |
| 441 | - | .where("id", "=", patch.id) | |
| 442 | - | .where("status", "=", "open") | |
| 443 | - | .executeTakeFirst(); | |
| 444 | - | if (!claimed || claimed.numUpdatedRows === 0n) | |
| 445 | - | return new Response("Patch is not open", { status: 400 }); | |
| 440 | + | // Reject if the patch file was changed after the admin loaded the page | |
| 441 | + | if (body.version !== patch.version) { | |
| 442 | + | return new Response( | |
| 443 | + | "The patch file was updated after you loaded this page. Please review the new version before merging.", | |
| 444 | + | { status: 409 }, | |
| 445 | + | ); | |
| 446 | + | } | |
| 446 | 447 | ||
| 447 | - | const mergeMeta = extractPatchMeta(patch.patch_content); | |
| 448 | - | try { | |
| 449 | - | await git.applyPatch( | |
| 450 | - | repo.name, | |
| 451 | - | patch.patch_content, | |
| 452 | - | mergeMeta.author, | |
| 453 | - | mergeMeta.email, | |
| 454 | - | COMMITTER_NAME, | |
| 455 | - | COMMITTER_EMAIL, | |
| 456 | - | ); | |
| 457 | - | } catch (err) { | |
| 458 | - | // Roll back the status if the git operation fails | |
| 448 | + | // Atomically claim the merge slot before the slow git operation to | |
| 449 | + | // prevent two concurrent requests from both applying the same patch. | |
| 450 | + | const claimed = await db | |
| 451 | + | .updateTable("patches") | |
| 452 | + | .set({ status: "merged", updated_at: new Date().toISOString() }) | |
| 453 | + | .where("id", "=", patch.id) | |
| 454 | + | .where("status", "=", "open") | |
| 455 | + | .where("version", "=", patch.version) | |
| 456 | + | .executeTakeFirst(); | |
| 457 | + | if (!claimed || claimed.numUpdatedRows === 0n) | |
| 458 | + | return new Response("Patch is not open", { status: 400 }); | |
| 459 | + | ||
| 460 | + | const mergeMeta = extractPatchMeta(patch.patch_content); | |
| 461 | + | try { | |
| 462 | + | await git.applyPatch( | |
| 463 | + | repo.name, | |
| 464 | + | patch.patch_content, | |
| 465 | + | mergeMeta.author, | |
| 466 | + | mergeMeta.email, | |
| 467 | + | COMMITTER_NAME, | |
| 468 | + | COMMITTER_EMAIL, | |
| 469 | + | ); | |
| 470 | + | } catch (err) { | |
| 471 | + | // Roll back the status if the git operation fails | |
| 472 | + | await db | |
| 473 | + | .updateTable("patches") | |
| 474 | + | .set({ | |
| 475 | + | status: "open", | |
| 476 | + | updated_at: new Date().toISOString(), | |
| 477 | + | }) | |
| 478 | + | .where("id", "=", patch.id) | |
| 479 | + | .execute(); | |
| 480 | + | throw err; | |
| 481 | + | } | |
| 482 | + | patchCache.invalidate(patch.id); | |
| 483 | + | ||
| 484 | + | return new Response(null, { | |
| 485 | + | status: 302, | |
| 486 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 487 | + | }); | |
| 488 | + | }, | |
| 489 | + | { | |
| 490 | + | body: t.Object({ version: t.String() }), | |
| 491 | + | }, | |
| 492 | + | ) | |
| 493 | + | ||
| 494 | + | .post( | |
| 495 | + | "/:repo/patches/:number/upload", | |
| 496 | + | async ({ params, body, cookie }) => { | |
| 497 | + | const user = await resolveSession(cookie.session.value); | |
| 498 | + | const deny = requireAuth(user); | |
| 499 | + | if (deny) return deny; | |
| 500 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 501 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 502 | + | ||
| 503 | + | const patchNum = parseInt(params.number, 10); | |
| 504 | + | const patch = await db | |
| 505 | + | .selectFrom("patches") | |
| 506 | + | .select(["id", "author_id", "status"]) | |
| 507 | + | .where("repo_id", "=", repo.id) | |
| 508 | + | .where("number", "=", patchNum) | |
| 509 | + | .executeTakeFirst(); | |
| 510 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 511 | + | if (patch.author_id !== user?.id && !user?.isAdmin) | |
| 512 | + | return new Response("Forbidden", { status: 403 }); | |
| 513 | + | if (patch.status !== "open") | |
| 514 | + | return new Response("Patch is not open", { status: 400 }); | |
| 515 | + | ||
| 516 | + | if (!body.patch_file || body.patch_file.size === 0) { | |
| 517 | + | return new Response("Patch file is required", { status: 400 }); | |
| 518 | + | } | |
| 519 | + | if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) { | |
| 520 | + | return new Response("Patch file is too large", { status: 400 }); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | const patchContent = await body.patch_file.text(); | |
| 524 | + | if (!patchContent.trim()) { | |
| 525 | + | return new Response("Patch file is empty", { status: 400 }); | |
| 526 | + | } | |
| 527 | + | if (!isValidPatch(patchContent)) { | |
| 528 | + | return new Response( | |
| 529 | + | "File does not appear to be a valid patch file", | |
| 530 | + | { status: 400 }, | |
| 531 | + | ); | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | const uploadMeta = extractPatchMeta(patchContent); | |
| 535 | + | if ( | |
| 536 | + | !uploadMeta.subject || | |
| 537 | + | !uploadMeta.author || | |
| 538 | + | !uploadMeta.email || | |
| 539 | + | !uploadMeta.date | |
| 540 | + | ) { | |
| 541 | + | return new Response( | |
| 542 | + | "Patch is missing required headers (Subject, From, Date)", | |
| 543 | + | { status: 400 }, | |
| 544 | + | ); | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | const newVersion = crypto.randomUUID(); | |
| 459 | 548 | await db | |
| 460 | 549 | .updateTable("patches") | |
| 461 | - | .set({ status: "open", updated_at: new Date().toISOString() }) | |
| 550 | + | .set({ | |
| 551 | + | patch_content: patchContent, | |
| 552 | + | author_name: uploadMeta.author, | |
| 553 | + | author_email: uploadMeta.email, | |
| 554 | + | version: newVersion, | |
| 555 | + | updated_at: new Date().toISOString(), | |
| 556 | + | }) | |
| 462 | 557 | .where("id", "=", patch.id) | |
| 463 | 558 | .execute(); | |
| 464 | - | throw err; | |
| 465 | - | } | |
| 466 | - | patchCache.invalidate(patch.id); | |
| 467 | 559 | ||
| 468 | - | return new Response(null, { | |
| 469 | - | status: 302, | |
| 470 | - | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 471 | - | }); | |
| 472 | - | }) | |
| 560 | + | patchCache.invalidate(patch.id); | |
| 561 | + | runPatchCheck(repo.name, patch.id, patchContent); | |
| 562 | + | ||
| 563 | + | return new Response(null, { | |
| 564 | + | status: 302, | |
| 565 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 566 | + | }); | |
| 567 | + | }, | |
| 568 | + | { | |
| 569 | + | body: t.Object({ | |
| 570 | + | patch_file: t.Optional(t.File()), | |
| 571 | + | }), | |
| 572 | + | }, | |
| 573 | + | ) | |
| 473 | 574 | ||
| 474 | 575 | .post("/:repo/patches/:number/close", async ({ params, cookie }) => { | |
| 475 | 576 | const user = await resolveSession(cookie.session.value); | |
Msrc/views/patches/PatchDetail.tsx
| @@ -133,6 +133,11 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 133 | 133 | action={`${baseUrl}/merge`} | |
| 134 | 134 | class="inline-form" | |
| 135 | 135 | > | |
| 136 | + | <input | |
| 137 | + | type="hidden" | |
| 138 | + | name="version" | |
| 139 | + | value={patch.version} | |
| 140 | + | /> | |
| 136 | 141 | <button | |
| 137 | 142 | type="submit" | |
| 138 | 143 | class="btn btn-sm btn-primary" | |
| @@ -141,6 +146,35 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 141 | 146 | </button> | |
| 142 | 147 | </form> | |
| 143 | 148 | )} | |
| 149 | + | {patch.status === "open" && ( | |
| 150 | + | <details class="confirm-details"> | |
| 151 | + | <summary class="btn btn-sm"> | |
| 152 | + | Update patch file | |
| 153 | + | </summary> | |
| 154 | + | <div class="confirm-popup"> | |
| 155 | + | <form | |
| 156 | + | method="POST" | |
| 157 | + | action={`${baseUrl}/upload`} | |
| 158 | + | enctype="multipart/form-data" | |
| 159 | + | class="upload-patch-form" | |
| 160 | + | > | |
| 161 | + | <input | |
| 162 | + | type="file" | |
| 163 | + | name="patch_file" | |
| 164 | + | accept=".patch" | |
| 165 | + | required | |
| 166 | + | class="form-input" | |
| 167 | + | /> | |
| 168 | + | <button | |
| 169 | + | type="submit" | |
| 170 | + | class="btn btn-sm btn-primary" | |
| 171 | + | > | |
| 172 | + | Upload | |
| 173 | + | </button> | |
| 174 | + | </form> | |
| 175 | + | </div> | |
| 176 | + | </details> | |
| 177 | + | )} | |
| 144 | 178 | {user?.isAdmin && patch.status !== "merged" && ( | |
| 145 | 179 | <form | |
| 146 | 180 | method="POST" | |
Mtests/e2e.test.ts
| @@ -1037,6 +1037,194 @@ describe('patches', () => { | |||
|---|---|---|---|
| 1037 | 1037 | expect(checkResp.status()).toBe(404); | |
| 1038 | 1038 | } finally { await page.close(); } | |
| 1039 | 1039 | }); | |
| 1040 | + | ||
| 1041 | + | // ── Patch file re-upload & version protection ────────────────────────────── | |
| 1042 | + | ||
| 1043 | + | let uploadTestPatchUrl: string; | |
| 1044 | + | ||
| 1045 | + | // Adds upload-test.txt — applies cleanly to my-repo | |
| 1046 | + | const UPLOAD_TEST_PATCH = [ | |
| 1047 | + | 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001', | |
| 1048 | + | 'From: Original Author <original@example.com>', | |
| 1049 | + | 'Date: Wed, 03 Jan 2024 10:00:00 +0000', | |
| 1050 | + | 'Subject: [PATCH] Add upload-test.txt', | |
| 1051 | + | '', | |
| 1052 | + | '---', | |
| 1053 | + | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 1054 | + | 'new file mode 100644', | |
| 1055 | + | 'index 0000000..9daeafb', | |
| 1056 | + | '--- /dev/null', | |
| 1057 | + | '+++ b/upload-test.txt', | |
| 1058 | + | '@@ -0,0 +1 @@', | |
| 1059 | + | '+upload test', | |
| 1060 | + | '', | |
| 1061 | + | ].join('\n'); | |
| 1062 | + | ||
| 1063 | + | // Replacement: different author, same diff target | |
| 1064 | + | const REPLACEMENT_PATCH = [ | |
| 1065 | + | 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001', | |
| 1066 | + | 'From: Replaced Author <replaced@example.com>', | |
| 1067 | + | 'Date: Thu, 04 Jan 2024 10:00:00 +0000', | |
| 1068 | + | 'Subject: [PATCH] Add upload-test.txt (v2)', | |
| 1069 | + | '', | |
| 1070 | + | '---', | |
| 1071 | + | 'diff --git a/upload-test.txt b/upload-test.txt', | |
| 1072 | + | 'new file mode 100644', | |
| 1073 | + | 'index 0000000..9daeafb', | |
| 1074 | + | '--- /dev/null', | |
| 1075 | + | '+++ b/upload-test.txt', | |
| 1076 | + | '@@ -0,0 +1 @@', | |
| 1077 | + | '+upload test v2', | |
| 1078 | + | '', | |
| 1079 | + | ].join('\n'); | |
| 1080 | + | ||
| 1081 | + | test('create patch for re-upload tests', async () => { | |
| 1082 | + | writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH); | |
| 1083 | + | const page = await adminCtx.newPage(); | |
| 1084 | + | try { | |
| 1085 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 1086 | + | await page.fill('[name=title]', 'Upload test patch'); | |
| 1087 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch'); | |
| 1088 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 1089 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 1090 | + | uploadTestPatchUrl = page.url(); | |
| 1091 | + | } finally { await page.close(); } | |
| 1092 | + | }); | |
| 1093 | + | ||
| 1094 | + | test('upload patch file button is visible for admin on open patch', async () => { | |
| 1095 | + | const page = await adminCtx.newPage(); | |
| 1096 | + | try { | |
| 1097 | + | await page.goto(uploadTestPatchUrl); | |
| 1098 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(1); | |
| 1099 | + | } finally { await page.close(); } | |
| 1100 | + | }); | |
| 1101 | + | ||
| 1102 | + | test('non-author non-admin cannot upload patch file', async () => { | |
| 1103 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1104 | + | const page = await aliceCtx.newPage(); | |
| 1105 | + | try { | |
| 1106 | + | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 1107 | + | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 1108 | + | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 1109 | + | maxRedirects: 0, | |
| 1110 | + | }); | |
| 1111 | + | expect(resp.status()).toBe(403); | |
| 1112 | + | } finally { await aliceCtx.close(); } | |
| 1113 | + | }); | |
| 1114 | + | ||
| 1115 | + | test('upload button hidden for non-author non-admin', async () => { | |
| 1116 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1117 | + | const page = await aliceCtx.newPage(); | |
| 1118 | + | try { | |
| 1119 | + | await page.goto(uploadTestPatchUrl); | |
| 1120 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 1121 | + | } finally { await aliceCtx.close(); } | |
| 1122 | + | }); | |
| 1123 | + | ||
| 1124 | + | test('admin can upload replacement patch file', async () => { | |
| 1125 | + | writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH); | |
| 1126 | + | const page = await adminCtx.newPage(); | |
| 1127 | + | try { | |
| 1128 | + | await page.goto(uploadTestPatchUrl); | |
| 1129 | + | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 1130 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch'); | |
| 1131 | + | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 1132 | + | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 1133 | + | // Author info should reflect the replacement patch | |
| 1134 | + | expect(await page.locator('.patch-author-identity').textContent()).toContain('Replaced Author'); | |
| 1135 | + | expect(await page.locator('.patch-author-identity').textContent()).toContain('replaced@example.com'); | |
| 1136 | + | } finally { await page.close(); } | |
| 1137 | + | }); | |
| 1138 | + | ||
| 1139 | + | test('merge fails when version token is stale', async () => { | |
| 1140 | + | // Patch that adds stale-version.txt — applies cleanly | |
| 1141 | + | const STALE_PATCH = [ | |
| 1142 | + | 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001', | |
| 1143 | + | 'From: Test User <test@example.com>', | |
| 1144 | + | 'Date: Fri, 05 Jan 2024 10:00:00 +0000', | |
| 1145 | + | 'Subject: [PATCH] Add stale-version.txt', | |
| 1146 | + | '', | |
| 1147 | + | '---', | |
| 1148 | + | 'diff --git a/stale-version.txt b/stale-version.txt', | |
| 1149 | + | 'new file mode 100644', | |
| 1150 | + | 'index 0000000..9daeafb', | |
| 1151 | + | '--- /dev/null', | |
| 1152 | + | '+++ b/stale-version.txt', | |
| 1153 | + | '@@ -0,0 +1 @@', | |
| 1154 | + | '+stale', | |
| 1155 | + | '', | |
| 1156 | + | ].join('\n'); | |
| 1157 | + | const STALE_PATCH_V2 = STALE_PATCH | |
| 1158 | + | .replace('Add stale-version.txt', 'Add stale-version.txt (v2)') | |
| 1159 | + | .replace('+stale', '+stale v2'); | |
| 1160 | + | ||
| 1161 | + | writeTempFile('/tmp/stale.patch', STALE_PATCH); | |
| 1162 | + | const page = await adminCtx.newPage(); | |
| 1163 | + | try { | |
| 1164 | + | // Create the patch | |
| 1165 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 1166 | + | await page.fill('[name=title]', 'Stale version test'); | |
| 1167 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch'); | |
| 1168 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 1169 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 1170 | + | const stalePatchUrl = page.url(); | |
| 1171 | + | const patchNum = stalePatchUrl.split('/patches/')[1]; | |
| 1172 | + | ||
| 1173 | + | // Capture the version the admin sees on the page | |
| 1174 | + | const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue(); | |
| 1175 | + | ||
| 1176 | + | // Author uploads a new patch file (simulated by admin here), bumping the version | |
| 1177 | + | writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2); | |
| 1178 | + | await page.locator('details:has([name=patch_file]) summary').click(); | |
| 1179 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch'); | |
| 1180 | + | await page.locator('details:has([name=patch_file]) button[type=submit]').click(); | |
| 1181 | + | await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, ''))); | |
| 1182 | + | ||
| 1183 | + | // Admin tries to merge with the stale version — should be rejected | |
| 1184 | + | const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, { | |
| 1185 | + | form: { version: staleVersion }, | |
| 1186 | + | maxRedirects: 0, | |
| 1187 | + | }); | |
| 1188 | + | expect(resp.status()).toBe(409); | |
| 1189 | + | expect(await resp.text()).toContain('updated'); | |
| 1190 | + | ||
| 1191 | + | // Patch status must still be open | |
| 1192 | + | const checkResp = await page.request.get(stalePatchUrl); | |
| 1193 | + | expect(checkResp.status()).toBe(200); | |
| 1194 | + | expect(await checkResp.text()).toContain('open'); | |
| 1195 | + | } finally { await page.close(); } | |
| 1196 | + | }); | |
| 1197 | + | ||
| 1198 | + | test('merge succeeds with current version token after replacement upload', async () => { | |
| 1199 | + | const page = await adminCtx.newPage(); | |
| 1200 | + | try { | |
| 1201 | + | await page.goto(uploadTestPatchUrl); | |
| 1202 | + | await page.click('form[action*="/merge"] button'); | |
| 1203 | + | await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, ''))); | |
| 1204 | + | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 1205 | + | // Merged commit should carry the replacement patch's author | |
| 1206 | + | const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; | |
| 1207 | + | const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); | |
| 1208 | + | expect(authorName).toBe('Replaced Author'); | |
| 1209 | + | } finally { await page.close(); } | |
| 1210 | + | }); | |
| 1211 | + | ||
| 1212 | + | test('upload patch file button hidden on merged patch', async () => { | |
| 1213 | + | const page = await adminCtx.newPage(); | |
| 1214 | + | try { | |
| 1215 | + | await page.goto(uploadTestPatchUrl); | |
| 1216 | + | expect(await page.locator('details:has([name=patch_file])').count()).toBe(0); | |
| 1217 | + | } finally { await page.close(); } | |
| 1218 | + | }); | |
| 1219 | + | ||
| 1220 | + | test('POST to upload on merged patch returns 400', async () => { | |
| 1221 | + | const patchNum = uploadTestPatchUrl.split('/patches/')[1]; | |
| 1222 | + | const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, { | |
| 1223 | + | multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } }, | |
| 1224 | + | maxRedirects: 0, | |
| 1225 | + | }); | |
| 1226 | + | expect(resp.status()).toBe(400); | |
| 1227 | + | }); | |
| 1040 | 1228 | }); | |
| 1041 | 1229 | ||
| 1042 | 1230 | // ─── Pagination ─────────────────────────────────────────────────────────────── | |