e2e.git-ops.test.ts
| 1 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; |
| 2 | import { chromium } from 'playwright'; |
| 3 | import type { Browser, BrowserContext } from 'playwright'; |
| 4 | import { |
| 5 | ADMIN_PASS, |
| 6 | BASE, |
| 7 | DATA_DIR, |
| 8 | getHeadCommit, |
| 9 | gitOutput, |
| 10 | killServer, |
| 11 | login, |
| 12 | seedBranch, |
| 13 | seedRepo, |
| 14 | setupTestEnv, |
| 15 | spawnServer, |
| 16 | } from './helpers.ts'; |
| 17 | |
| 18 | let browser: Browser; |
| 19 | let server: Awaited<ReturnType<typeof spawnServer>>; |
| 20 | let adminCtx: BrowserContext; |
| 21 | |
| 22 | beforeAll(async () => { |
| 23 | await setupTestEnv(); |
| 24 | server = await spawnServer(); |
| 25 | browser = await chromium.launch(); |
| 26 | |
| 27 | // Single admin context shared across all describes |
| 28 | adminCtx = await browser.newContext(); |
| 29 | const page = await adminCtx.newPage(); |
| 30 | await login(page, 'admin', ADMIN_PASS); |
| 31 | await page.close(); |
| 32 | |
| 33 | // Create all repos upfront |
| 34 | for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) { |
| 35 | const p = await adminCtx.newPage(); |
| 36 | try { |
| 37 | await p.goto(`${BASE}/new`); |
| 38 | await p.fill('[name=name]', name); |
| 39 | await p.click('form[action="/new"] button[type=submit]'); |
| 40 | await p.waitForURL(`${BASE}/${name}`); |
| 41 | } finally { await p.close(); } |
| 42 | } |
| 43 | |
| 44 | // Seed repos |
| 45 | await seedRepo('branch-repo'); |
| 46 | seedBranch('branch-repo', 'feature-a'); |
| 47 | |
| 48 | await seedRepo('tag-repo'); |
| 49 | gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']); |
| 50 | |
| 51 | await seedRepo('selector-repo'); |
| 52 | gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']); |
| 53 | |
| 54 | await seedRepo('newfile-repo'); |
| 55 | await seedRepo('delfile-repo'); |
| 56 | await seedRepo('movefile-repo'); |
| 57 | }); |
| 58 | |
| 59 | afterAll(async () => { |
| 60 | await adminCtx.close(); |
| 61 | await browser.close(); |
| 62 | await killServer(server); |
| 63 | }); |
| 64 | |
| 65 | function repoDir(name: string) { |
| 66 | return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; |
| 67 | } |
| 68 | |
| 69 | // ─── Branch management ──────────────────────────────────────────────────────── |
| 70 | |
| 71 | describe('branches', () => { |
| 72 | test('Branches tab appears in repo nav', async () => { |
| 73 | const page = await adminCtx.newPage(); |
| 74 | try { |
| 75 | await page.goto(`${BASE}/branch-repo`); |
| 76 | const tab = page.locator('a.repo-tab', { hasText: 'Branches' }); |
| 77 | expect(await tab.isVisible()).toBe(true); |
| 78 | } finally { await page.close(); } |
| 79 | }); |
| 80 | |
| 81 | test('branches page lists all branches', async () => { |
| 82 | const page = await adminCtx.newPage(); |
| 83 | try { |
| 84 | await page.goto(`${BASE}/branch-repo/branches`); |
| 85 | const content = await page.content(); |
| 86 | expect(content).toContain('main'); |
| 87 | expect(content).toContain('feature-a'); |
| 88 | } finally { await page.close(); } |
| 89 | }); |
| 90 | |
| 91 | test('default branch has a "default" badge', async () => { |
| 92 | const page = await adminCtx.newPage(); |
| 93 | try { |
| 94 | await page.goto(`${BASE}/branch-repo/branches`); |
| 95 | expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0); |
| 96 | } finally { await page.close(); } |
| 97 | }); |
| 98 | |
| 99 | test('branch list shows last commit hash and subject', async () => { |
| 100 | const sha = getHeadCommit('branch-repo').slice(0, 7); |
| 101 | const page = await adminCtx.newPage(); |
| 102 | try { |
| 103 | await page.goto(`${BASE}/branch-repo/branches`); |
| 104 | const content = await page.content(); |
| 105 | expect(content).toContain(sha); |
| 106 | expect(content).toContain('Initial commit'); |
| 107 | } finally { await page.close(); } |
| 108 | }); |
| 109 | |
| 110 | test('branches page returns 404 for non-existent repo', async () => { |
| 111 | const ctx = await browser.newContext(); |
| 112 | const page = await ctx.newPage(); |
| 113 | try { |
| 114 | const resp = await page.request.get(`${BASE}/does-not-exist/branches`); |
| 115 | expect(resp.status()).toBe(404); |
| 116 | } finally { |
| 117 | await page.close(); |
| 118 | await ctx.close(); |
| 119 | } |
| 120 | }); |
| 121 | |
| 122 | test('create a new branch', async () => { |
| 123 | const page = await adminCtx.newPage(); |
| 124 | try { |
| 125 | await page.goto(`${BASE}/branch-repo/branches`); |
| 126 | await page.click('details:has(summary:text("New branch")) summary'); |
| 127 | await page.fill('[name=name]', 'new-feature'); |
| 128 | await page.fill('[name=source_ref]', 'main'); |
| 129 | await page.click('form[action*="branches/create"] button[type=submit]'); |
| 130 | await page.waitForURL(/branches/); |
| 131 | expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0); |
| 132 | } finally { await page.close(); } |
| 133 | }); |
| 134 | |
| 135 | test('creating branch with invalid name shows error', async () => { |
| 136 | const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, { |
| 137 | form: { name: '--invalid', source_ref: 'main' }, |
| 138 | }); |
| 139 | expect(resp.url()).toContain('error'); |
| 140 | }); |
| 141 | |
| 142 | test('creating branch from non-existent ref shows error', async () => { |
| 143 | const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, { |
| 144 | form: { name: 'bad-branch', source_ref: 'does-not-exist' }, |
| 145 | }); |
| 146 | expect(resp.url()).toContain('error'); |
| 147 | }); |
| 148 | |
| 149 | test('rename a branch', async () => { |
| 150 | const page = await adminCtx.newPage(); |
| 151 | try { |
| 152 | await page.goto(`${BASE}/branch-repo/branches`); |
| 153 | const row = page.locator('.ref-item').filter({ hasText: 'feature-a' }); |
| 154 | await row.locator('details:has(summary:text("Rename")) summary').click(); |
| 155 | await row.locator('[name=new_name]').fill('feature-renamed'); |
| 156 | await row.locator('form[action*="branches/rename"] button[type=submit]').click(); |
| 157 | await page.waitForURL(/branches/); |
| 158 | expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0); |
| 159 | expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0); |
| 160 | } finally { await page.close(); } |
| 161 | }); |
| 162 | |
| 163 | test('delete a non-default branch', async () => { |
| 164 | const page = await adminCtx.newPage(); |
| 165 | try { |
| 166 | await page.goto(`${BASE}/branch-repo/branches`); |
| 167 | const row = page.locator('.ref-item').filter({ hasText: 'new-feature' }); |
| 168 | await row.locator('details:has(summary:text("Delete")) summary').click(); |
| 169 | await row.locator('form[action*="branches/delete"] button[type=submit]').click(); |
| 170 | await page.waitForURL(/branches/); |
| 171 | expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0); |
| 172 | } finally { await page.close(); } |
| 173 | }); |
| 174 | |
| 175 | test('cannot delete the default branch (no Delete button on main row)', async () => { |
| 176 | const page = await adminCtx.newPage(); |
| 177 | try { |
| 178 | await page.goto(`${BASE}/branch-repo/branches`); |
| 179 | const mainRow = page.locator('.ref-item').filter({ hasText: 'main' }); |
| 180 | expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0); |
| 181 | } finally { await page.close(); } |
| 182 | }); |
| 183 | |
| 184 | test('renaming default branch updates it in repo', async () => { |
| 185 | const page = await adminCtx.newPage(); |
| 186 | try { |
| 187 | await page.goto(`${BASE}/branch-repo/branches`); |
| 188 | const row = page.locator('.ref-item').filter({ hasText: 'main' }); |
| 189 | await row.locator('details:has(summary:text("Rename")) summary').click(); |
| 190 | await row.locator('[name=new_name]').fill('trunk'); |
| 191 | await row.locator('form[action*="branches/rename"] button[type=submit]').click(); |
| 192 | await page.waitForURL(/branches/); |
| 193 | expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0); |
| 194 | // Rename back |
| 195 | await page.goto(`${BASE}/branch-repo/branches`); |
| 196 | const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' }); |
| 197 | await trunkRow.locator('details:has(summary:text("Rename")) summary').click(); |
| 198 | await trunkRow.locator('[name=new_name]').fill('main'); |
| 199 | await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click(); |
| 200 | await page.waitForURL(/branches/); |
| 201 | } finally { await page.close(); } |
| 202 | }); |
| 203 | }); |
| 204 | |
| 205 | // ─── Tag management ─────────────────────────────────────────────────────────── |
| 206 | |
| 207 | describe('tags', () => { |
| 208 | test('Tags tab appears in repo nav', async () => { |
| 209 | const page = await adminCtx.newPage(); |
| 210 | try { |
| 211 | await page.goto(`${BASE}/tag-repo`); |
| 212 | const tab = page.locator('a.repo-tab', { hasText: 'Tags' }); |
| 213 | expect(await tab.isVisible()).toBe(true); |
| 214 | } finally { await page.close(); } |
| 215 | }); |
| 216 | |
| 217 | test('tags page lists existing tags', async () => { |
| 218 | const page = await adminCtx.newPage(); |
| 219 | try { |
| 220 | await page.goto(`${BASE}/tag-repo/tags`); |
| 221 | expect(await page.content()).toContain('v0.1.0'); |
| 222 | } finally { await page.close(); } |
| 223 | }); |
| 224 | |
| 225 | test('tag links to correct tree view', async () => { |
| 226 | const page = await adminCtx.newPage(); |
| 227 | try { |
| 228 | await page.goto(`${BASE}/tag-repo/tags`); |
| 229 | const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`); |
| 230 | expect(await link.count()).toBeGreaterThan(0); |
| 231 | } finally { await page.close(); } |
| 232 | }); |
| 233 | |
| 234 | test('create a new tag', async () => { |
| 235 | const page = await adminCtx.newPage(); |
| 236 | try { |
| 237 | await page.goto(`${BASE}/tag-repo/tags`); |
| 238 | await page.click('details:has(summary:text("New tag")) summary'); |
| 239 | await page.fill('[name=name]', 'v1.0.0'); |
| 240 | await page.click('form[action*="tags/create"] button[type=submit]'); |
| 241 | await page.waitForURL(/tags/); |
| 242 | expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0); |
| 243 | } finally { await page.close(); } |
| 244 | }); |
| 245 | |
| 246 | test('create annotated tag with message', async () => { |
| 247 | const page = await adminCtx.newPage(); |
| 248 | try { |
| 249 | await page.goto(`${BASE}/tag-repo/tags`); |
| 250 | await page.click('details:has(summary:text("New tag")) summary'); |
| 251 | await page.fill('[name=name]', 'v1.1.0-annotated'); |
| 252 | await page.fill('[name=message]', 'Annotated release tag'); |
| 253 | await page.click('form[action*="tags/create"] button[type=submit]'); |
| 254 | await page.waitForURL(/tags/); |
| 255 | expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0); |
| 256 | } finally { await page.close(); } |
| 257 | }); |
| 258 | |
| 259 | test('delete a tag', async () => { |
| 260 | const page = await adminCtx.newPage(); |
| 261 | try { |
| 262 | await page.goto(`${BASE}/tag-repo/tags`); |
| 263 | const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' }); |
| 264 | await row.locator('details:has(summary:text("Delete")) summary').click(); |
| 265 | await row.locator('form[action*="tags/delete"] button[type=submit]').click(); |
| 266 | await page.waitForURL(/tags/); |
| 267 | expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0); |
| 268 | expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0); |
| 269 | } finally { await page.close(); } |
| 270 | }); |
| 271 | |
| 272 | test('tag linked to release shows release badge and warning on delete', async () => { |
| 273 | const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, { |
| 274 | multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' }, |
| 275 | maxRedirects: 0, |
| 276 | }); |
| 277 | expect(resp.status()).toBe(302); |
| 278 | |
| 279 | const page = await adminCtx.newPage(); |
| 280 | try { |
| 281 | await page.goto(`${BASE}/tag-repo/tags`); |
| 282 | const row = page.locator('.ref-item').filter({ hasText: 'v-linked' }); |
| 283 | expect(await row.locator('.badge-release').count()).toBeGreaterThan(0); |
| 284 | await row.locator('details:has(summary:text("Delete")) summary').click(); |
| 285 | expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0); |
| 286 | } finally { await page.close(); } |
| 287 | }); |
| 288 | }); |
| 289 | |
| 290 | // ─── BranchSelector with tags ───────────────────────────────────────────────── |
| 291 | |
| 292 | describe('BranchSelector tags integration', () => { |
| 293 | test('branch selector shows Tags optgroup when tags exist', async () => { |
| 294 | const page = await adminCtx.newPage(); |
| 295 | try { |
| 296 | await page.goto(`${BASE}/selector-repo`); |
| 297 | expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1); |
| 298 | expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0); |
| 299 | } finally { await page.close(); } |
| 300 | }); |
| 301 | |
| 302 | test('branch selector shows Branches optgroup when tags exist', async () => { |
| 303 | const page = await adminCtx.newPage(); |
| 304 | try { |
| 305 | await page.goto(`${BASE}/selector-repo`); |
| 306 | expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1); |
| 307 | } finally { await page.close(); } |
| 308 | }); |
| 309 | |
| 310 | test('branch selector on blob view includes tag optgroup', async () => { |
| 311 | const page = await adminCtx.newPage(); |
| 312 | try { |
| 313 | await page.goto(`${BASE}/selector-repo/blob/main/README.md`); |
| 314 | expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1); |
| 315 | } finally { await page.close(); } |
| 316 | }); |
| 317 | }); |
| 318 | |
| 319 | // ─── File creation ──────────────────────────────────────────────────────────── |
| 320 | |
| 321 | describe('file creation', () => { |
| 322 | test('New file button appears in tree toolbar for admin on a branch', async () => { |
| 323 | const page = await adminCtx.newPage(); |
| 324 | try { |
| 325 | await page.goto(`${BASE}/newfile-repo/tree/main`); |
| 326 | expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0); |
| 327 | } finally { await page.close(); } |
| 328 | }); |
| 329 | |
| 330 | test('New file button not visible on commit SHA view', async () => { |
| 331 | const sha = getHeadCommit('newfile-repo'); |
| 332 | const page = await adminCtx.newPage(); |
| 333 | try { |
| 334 | await page.goto(`${BASE}/newfile-repo/tree/${sha}`); |
| 335 | expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0); |
| 336 | } finally { await page.close(); } |
| 337 | }); |
| 338 | |
| 339 | test('New file button not visible to unauthenticated user', async () => { |
| 340 | const ctx = await browser.newContext(); |
| 341 | const page = await ctx.newPage(); |
| 342 | try { |
| 343 | await page.goto(`${BASE}/newfile-repo/tree/main`); |
| 344 | expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0); |
| 345 | } finally { |
| 346 | await page.close(); |
| 347 | await ctx.close(); |
| 348 | } |
| 349 | }); |
| 350 | |
| 351 | test('new file form pre-fills dir when ?dir= query param is provided', async () => { |
| 352 | const page = await adminCtx.newPage(); |
| 353 | try { |
| 354 | await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`); |
| 355 | const pathInput = await page.locator('[name=path]').inputValue(); |
| 356 | expect(pathInput).toBe('src/'); |
| 357 | } finally { await page.close(); } |
| 358 | }); |
| 359 | |
| 360 | test('creating a new file creates a commit and redirects to commit view', async () => { |
| 361 | const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, { |
| 362 | form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' }, |
| 363 | maxRedirects: 0, |
| 364 | }); |
| 365 | expect(resp.status()).toBe(302); |
| 366 | expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/); |
| 367 | }); |
| 368 | |
| 369 | test('new file appears in tree after creation', async () => { |
| 370 | const page = await adminCtx.newPage(); |
| 371 | try { |
| 372 | await page.goto(`${BASE}/newfile-repo/tree/main`); |
| 373 | expect(await page.content()).toContain('hello.txt'); |
| 374 | } finally { await page.close(); } |
| 375 | }); |
| 376 | |
| 377 | test('new file commit is signed', async () => { |
| 378 | const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']); |
| 379 | expect(hash).toBeTruthy(); |
| 380 | const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]); |
| 381 | expect(obj).toContain('gpgsig'); |
| 382 | }); |
| 383 | |
| 384 | test('creating file with invalid path shows error', async () => { |
| 385 | const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, { |
| 386 | form: { path: '../escape', content: '', message: 'bad' }, |
| 387 | }); |
| 388 | expect(resp.url()).toContain('error'); |
| 389 | }); |
| 390 | }); |
| 391 | |
| 392 | // ─── File deletion ──────────────────────────────────────────────────────────── |
| 393 | |
| 394 | describe('file deletion', () => { |
| 395 | test('Delete button appears in file blob for admin on a branch', async () => { |
| 396 | const page = await adminCtx.newPage(); |
| 397 | try { |
| 398 | await page.goto(`${BASE}/delfile-repo/blob/main/index.js`); |
| 399 | expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0); |
| 400 | } finally { await page.close(); } |
| 401 | }); |
| 402 | |
| 403 | test('Delete button not visible to unauthenticated user', async () => { |
| 404 | const ctx = await browser.newContext(); |
| 405 | const page = await ctx.newPage(); |
| 406 | try { |
| 407 | await page.goto(`${BASE}/delfile-repo/blob/main/index.js`); |
| 408 | expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0); |
| 409 | } finally { |
| 410 | await page.close(); |
| 411 | await ctx.close(); |
| 412 | } |
| 413 | }); |
| 414 | |
| 415 | test('deleting a file creates a commit and removes it from the tree', async () => { |
| 416 | const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, { |
| 417 | form: { message: 'Remove index.js' }, |
| 418 | maxRedirects: 0, |
| 419 | }); |
| 420 | expect(resp.status()).toBe(302); |
| 421 | expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/); |
| 422 | |
| 423 | const page = await adminCtx.newPage(); |
| 424 | try { |
| 425 | await page.goto(`${BASE}/delfile-repo/tree/main`); |
| 426 | expect(await page.content()).not.toContain('index.js'); |
| 427 | } finally { await page.close(); } |
| 428 | }); |
| 429 | |
| 430 | test('delete commit is signed', async () => { |
| 431 | const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']); |
| 432 | expect(hash).toBeTruthy(); |
| 433 | const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]); |
| 434 | expect(obj).toContain('gpgsig'); |
| 435 | }); |
| 436 | }); |
| 437 | |
| 438 | // ─── File rename/move ───────────────────────────────────────────────────────── |
| 439 | |
| 440 | describe('file rename/move', () => { |
| 441 | test('edit form has new_path input pre-filled with current path', async () => { |
| 442 | const page = await adminCtx.newPage(); |
| 443 | try { |
| 444 | await page.goto(`${BASE}/movefile-repo/edit/main/index.js`); |
| 445 | const newPathInput = await page.locator('[name=new_path]').inputValue(); |
| 446 | expect(newPathInput).toBe('index.js'); |
| 447 | } finally { await page.close(); } |
| 448 | }); |
| 449 | |
| 450 | test('renaming a file via edit creates a commit and old path is gone', async () => { |
| 451 | const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, { |
| 452 | form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' }, |
| 453 | maxRedirects: 0, |
| 454 | }); |
| 455 | expect(resp.status()).toBe(302); |
| 456 | expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/); |
| 457 | |
| 458 | const page = await adminCtx.newPage(); |
| 459 | try { |
| 460 | await page.goto(`${BASE}/movefile-repo/tree/main`); |
| 461 | expect(await page.content()).toContain('app.js'); |
| 462 | expect(await page.content()).not.toContain('index.js'); |
| 463 | } finally { await page.close(); } |
| 464 | }); |
| 465 | |
| 466 | test('rename commit is signed', async () => { |
| 467 | const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']); |
| 468 | expect(hash).toBeTruthy(); |
| 469 | const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]); |
| 470 | expect(obj).toContain('gpgsig'); |
| 471 | }); |
| 472 | |
| 473 | test('renaming to invalid path shows error', async () => { |
| 474 | const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, { |
| 475 | form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' }, |
| 476 | maxRedirects: 0, |
| 477 | }); |
| 478 | expect(resp.status()).toBe(302); |
| 479 | expect(resp.headers()['location']).toContain('error'); |
| 480 | }); |
| 481 | }); |
| 482 |