e2e.settings.test.ts
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import { rmSync, readFileSync } from 'node:fs';
5import { spawnSync as nodeSpawnSync } from 'node:child_process';
6import {
7 BASE,
8 ADMIN_PASS,
9 setupTestEnv,
10 spawnServer,
11 killServer,
12 login,
13 seedRepo,
14} from './helpers.ts';
15
16let browser: Browser;
17let server: Awaited<ReturnType<typeof spawnServer>>;
18
19beforeAll(async () => {
20 await setupTestEnv();
21 server = await spawnServer();
22 browser = await chromium.launch();
23
24 // Register alice
25 const regCtx = await browser.newContext();
26 const regPage = await regCtx.newPage();
27 try {
28 await regPage.goto(`${BASE}/register`);
29 await regPage.fill('[name=username]', 'alice');
30 await regPage.fill('[name=password]', 'password123');
31 await regPage.fill('[name=password2]', 'password123');
32 await regPage.click('button[type=submit]');
33 await regPage.waitForURL(BASE + '/');
34 } finally { await regCtx.close(); }
35
36 // Create my-repo (needed for repo deletion non-admin test)
37 const adminCtx = await browser.newContext();
38 const adminPage = await adminCtx.newPage();
39 try {
40 await login(adminPage);
41 await adminPage.goto(`${BASE}/new`);
42 await adminPage.fill('[name=name]', 'my-repo');
43 await adminPage.click('form[action="/new"] button[type=submit]');
44 await adminPage.waitForURL(`${BASE}/my-repo`);
45 } finally { await adminCtx.close(); }
46
47 await seedRepo('my-repo');
48});
49
50afterAll(async () => {
51 await browser.close();
52 await killServer(server);
53});
54
55async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
56 const ctx = await browser.newContext();
57 const page = await ctx.newPage();
58 await login(page, username, password);
59 await page.close();
60 return ctx;
61}
62
63// ─── Settings ─────────────────────────────────────────────────────────────────
64
65describe('settings', () => {
66 let adminCtx: BrowserContext;
67 let aliceCtx: BrowserContext;
68 // Public key generated once in beforeAll, reused across SSH key tests
69 let testPubKey: string;
70
71 beforeAll(async () => {
72 adminCtx = await loggedInContext();
73 aliceCtx = await loggedInContext('alice', 'password123');
74
75 // Generate a throwaway ed25519 key for SSH key tests.
76 const keyPath = '/tmp/hf-e2e-sshkey';
77 rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
78 nodeSpawnSync(
79 'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
80 { stdio: 'ignore' },
81 );
82 testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim();
83 rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true });
84 });
85
86 afterAll(async () => {
87 await adminCtx.close();
88 await aliceCtx.close();
89 });
90
91 test('settings page requires auth', async () => {
92 const ctx = await browser.newContext();
93 const page = await ctx.newPage();
94 try {
95 await page.goto(`${BASE}/settings`);
96 expect(page.url()).toContain('/login');
97 } finally { await ctx.close(); }
98 });
99
100 test('settings page loads for logged-in user', async () => {
101 const page = await adminCtx.newPage();
102 try {
103 await page.goto(`${BASE}/settings`);
104 expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
105 } finally { await page.close(); }
106 });
107
108 // ── Password ──────────────────────────────────────────────────────────────
109
110 test('password change with mismatched passwords shows error', async () => {
111 const page = await aliceCtx.newPage();
112 try {
113 await page.goto(`${BASE}/settings`);
114 await page.fill('[name=new_password]', 'newpass123');
115 await page.fill('[name=confirm_password]', 'different456');
116 await page.click('form[action="/settings/password"] button[type=submit]');
117 await page.waitForURL(/\/settings/);
118 expect(page.url()).toContain('error');
119 } finally { await page.close(); }
120 });
121
122 test('password change with wrong current password shows error', async () => {
123 const page = await aliceCtx.newPage();
124 try {
125 await page.goto(`${BASE}/settings`);
126 await page.fill('[name=current_password]', 'wrongpassword');
127 await page.fill('[name=new_password]', 'newpass123');
128 await page.fill('[name=confirm_password]', 'newpass123');
129 await page.click('form[action="/settings/password"] button[type=submit]');
130 await page.waitForURL(/\/settings/);
131 expect(page.url()).toContain('error');
132 } finally { await page.close(); }
133 });
134
135 test('password change too short shows error', async () => {
136 const page = await aliceCtx.newPage();
137 try {
138 await page.goto(`${BASE}/settings`);
139 await page.fill('[name=current_password]', 'password123');
140 await page.fill('[name=new_password]', 'short');
141 await page.fill('[name=confirm_password]', 'short');
142 await page.click('form[action="/settings/password"] button[type=submit]');
143 await page.waitForURL(/\/settings/);
144 expect(page.url()).toContain('error');
145 } finally { await page.close(); }
146 });
147
148 // ── SSH keys ──────────────────────────────────────────────────────────────
149
150 test('add SSH key with unsupported key type shows error', async () => {
151 const page = await adminCtx.newPage();
152 try {
153 await page.goto(`${BASE}/settings`);
154 await page.fill('#ssh_key_name', 'Bad key');
155 await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
156 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
157 await page.waitForURL(/\/settings/);
158 expect(page.url()).toContain('error');
159 } finally { await page.close(); }
160 });
161
162 test('add valid SSH key shows success and key appears in list', async () => {
163 const page = await adminCtx.newPage();
164 try {
165 await page.goto(`${BASE}/settings`);
166 await page.fill('#ssh_key_name', 'My Laptop');
167 await page.fill('#ssh_public_key', testPubKey);
168 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
169 await page.waitForURL(/\/settings/);
170 expect(page.url()).toContain('success=ssh_key_added');
171 await page.goto(`${BASE}/settings`);
172 expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
173 } finally { await page.close(); }
174 });
175
176 test('add duplicate SSH key shows error', async () => {
177 const page = await adminCtx.newPage();
178 try {
179 await page.goto(`${BASE}/settings`);
180 await page.fill('#ssh_key_name', 'Duplicate');
181 await page.fill('#ssh_public_key', testPubKey);
182 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
183 await page.waitForURL(/\/settings/);
184 expect(page.url()).toContain('error');
185 } finally { await page.close(); }
186 });
187
188 test('delete SSH key removes it from list', async () => {
189 const page = await adminCtx.newPage();
190 try {
191 await page.goto(`${BASE}/settings`);
192 // Click the Remove button for the key added above
193 await page.click('form[action="/settings/ssh-keys/delete"] button');
194 await page.waitForURL(/\/settings/);
195 expect(page.url()).toContain('success=ssh_key_deleted');
196 await page.goto(`${BASE}/settings`);
197 expect(await page.locator('.ssh-key-name').count()).toBe(0);
198 } finally { await page.close(); }
199 });
200
201 // ── Admin user management ────────────────────────────────────────────────
202
203 test('admin can create a new user account', async () => {
204 const page = await adminCtx.newPage();
205 try {
206 await page.goto(`${BASE}/settings`);
207 await page.fill('#new_username', 'charlie');
208 await page.fill('#new_user_password', 'charliepw1');
209 await page.click('form[action="/admin/users"] button[type=submit]');
210 await page.waitForURL(/\/settings/);
211 expect(page.url()).toContain('success=user_created');
212 } finally { await page.close(); }
213 });
214
215 test('admin cannot create duplicate username', async () => {
216 const page = await adminCtx.newPage();
217 try {
218 await page.goto(`${BASE}/settings`);
219 await page.fill('#new_username', 'charlie');
220 await page.fill('#new_user_password', 'charliepw1');
221 await page.click('form[action="/admin/users"] button[type=submit]');
222 await page.waitForURL(/\/settings/);
223 expect(page.url()).toContain('error');
224 } finally { await page.close(); }
225 });
226
227 test('admin cannot create user with invalid username characters', async () => {
228 const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
229 form: { username: 'bad user!', password: 'password123' },
230 maxRedirects: 0,
231 });
232 expect(resp.status()).toBe(302);
233 const location = resp.headers()['location'] ?? '';
234 expect(location).toContain('error');
235 });
236
237 test('non-admin gets 403 when creating user', async () => {
238 const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
239 form: { username: 'hacker', password: 'password123' },
240 maxRedirects: 0,
241 });
242 expect(resp.status()).toBe(403);
243 });
244
245 test('admin can delete user account', async () => {
246 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
247 form: { username: 'charlie' },
248 maxRedirects: 0,
249 });
250 expect(resp.status()).toBe(302);
251 expect(resp.headers()['location']).toContain('success=user_deleted');
252 });
253
254 test('admin cannot delete the admin account', async () => {
255 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
256 form: { username: 'admin' },
257 maxRedirects: 0,
258 });
259 expect(resp.status()).toBe(302);
260 expect(resp.headers()['location']).toContain('error');
261 });
262
263 test('settings page has no git identity section', async () => {
264 const page = await adminCtx.newPage();
265 try {
266 await page.goto(`${BASE}/settings`);
267 expect(await page.locator('text=Git Identity').count()).toBe(0);
268 expect(await page.locator('[name=git_name]').count()).toBe(0);
269 expect(await page.locator('[name=git_email]').count()).toBe(0);
270 } finally { await page.close(); }
271 });
272
273 test('git identity route no longer exists', async () => {
274 const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
275 form: { git_name: 'Test', git_email: 'test@example.com' },
276 maxRedirects: 0,
277 });
278 expect(resp.status()).toBe(404);
279 });
280});
281
282// ─── Repository deletion ──────────────────────────────────────────────────────
283
284describe('repository deletion', () => {
285 let adminCtx: BrowserContext;
286
287 beforeAll(async () => {
288 adminCtx = await loggedInContext();
289
290 // Create a repo to delete
291 const page = await adminCtx.newPage();
292 try {
293 await page.goto(`${BASE}/new`);
294 await page.fill('[name=name]', 'deleteme-repo');
295 await page.click('form[action="/new"] button[type=submit]');
296 await page.waitForURL(`${BASE}/deleteme-repo`);
297 } finally { await page.close(); }
298 });
299
300 afterAll(async () => { await adminCtx.close(); });
301
302 test('admin can delete repository', async () => {
303 const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
304 maxRedirects: 0,
305 });
306 expect(resp.status()).toBe(302);
307 expect(resp.headers()['location']).toBe('/');
308 });
309
310 test('deleted repository returns 404', async () => {
311 const page = await adminCtx.newPage();
312 try {
313 const resp = await page.request.get(`${BASE}/deleteme-repo`);
314 expect(resp.status()).toBe(404);
315 } finally { await page.close(); }
316 });
317
318 test('deleted repository no longer appears in list', async () => {
319 const page = await adminCtx.newPage();
320 try {
321 await page.goto(BASE);
322 expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
323 } finally { await page.close(); }
324 });
325
326 test('non-admin cannot delete repository', async () => {
327 const aliceCtx = await loggedInContext('alice', 'password123');
328 const page = await aliceCtx.newPage();
329 try {
330 const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
331 maxRedirects: 0,
332 });
333 expect(resp.status()).toBe(403);
334 } finally {
335 await page.close();
336 await aliceCtx.close();
337 }
338 });
339});
340
341// ─── Repository rename ───────────────────────────────────────────────────────
342
343describe('repository rename', () => {
344 let adminCtx: BrowserContext;
345
346 beforeAll(async () => {
347 adminCtx = await loggedInContext();
348
349 const page = await adminCtx.newPage();
350 try {
351 await page.goto(`${BASE}/new`);
352 await page.fill('[name=name]', 'renameme-repo');
353 await page.click('form[action="/new"] button[type=submit]');
354 await page.waitForURL(`${BASE}/renameme-repo`);
355
356 await page.goto(`${BASE}/new`);
357 await page.fill('[name=name]', 'rename-other');
358 await page.click('form[action="/new"] button[type=submit]');
359 await page.waitForURL(`${BASE}/rename-other`);
360 } finally { await page.close(); }
361 });
362
363 afterAll(async () => { await adminCtx.close(); });
364
365 test('rejects wrong confirmation', async () => {
366 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
367 form: { new_name: 'whatever', confirm_name: 'wrong' },
368 maxRedirects: 0,
369 });
370 expect(resp.status()).toBe(302);
371 const loc = resp.headers()['location']!;
372 expect(loc).toContain('/renameme-repo/settings?error=');
373 expect(decodeURIComponent(loc)).toContain('Confirmation');
374
375 const check = await adminCtx.request.get(`${BASE}/renameme-repo`);
376 expect(check.status()).toBe(200);
377 });
378
379 test('rejects invalid name', async () => {
380 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
381 form: { new_name: 'bad name', confirm_name: 'renameme-repo' },
382 maxRedirects: 0,
383 });
384 expect(resp.status()).toBe(302);
385 const loc = resp.headers()['location']!;
386 expect(loc).toContain('/renameme-repo/settings?error=');
387 expect(decodeURIComponent(loc)).toContain('Invalid');
388 });
389
390 test('rejects no-op rename', async () => {
391 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
392 form: { new_name: 'renameme-repo', confirm_name: 'renameme-repo' },
393 maxRedirects: 0,
394 });
395 expect(resp.status()).toBe(302);
396 expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name');
397 });
398
399 test('rejects duplicate name', async () => {
400 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
401 form: { new_name: 'rename-other', confirm_name: 'renameme-repo' },
402 maxRedirects: 0,
403 });
404 expect(resp.status()).toBe(302);
405 expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken');
406 });
407
408 test('non-admin cannot rename', async () => {
409 const aliceCtx = await loggedInContext('alice', 'password123');
410 try {
411 const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
412 form: { new_name: 'hijack', confirm_name: 'renameme-repo' },
413 maxRedirects: 0,
414 });
415 expect(resp.status()).toBe(403);
416 } finally { await aliceCtx.close(); }
417 });
418
419 test('admin can rename repository', async () => {
420 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
421 form: { new_name: 'renamed-repo', confirm_name: 'renameme-repo' },
422 maxRedirects: 0,
423 });
424 expect(resp.status()).toBe(302);
425 const loc = resp.headers()['location']!;
426 expect(loc).toContain('/renamed-repo/settings?success=');
427
428 const old = await adminCtx.request.get(`${BASE}/renameme-repo`);
429 expect(old.status()).toBe(404);
430
431 const next = await adminCtx.request.get(`${BASE}/renamed-repo`);
432 expect(next.status()).toBe(200);
433 });
434});
435
436// ─── 404 handling ─────────────────────────────────────────────────────────────
437
438describe('404 handling', () => {
439 let adminCtx: BrowserContext;
440
441 beforeAll(async () => { adminCtx = await loggedInContext(); });
442 afterAll(async () => { await adminCtx.close(); });
443
444 test('non-existent repository returns 404', async () => {
445 const page = await adminCtx.newPage();
446 try {
447 const resp = await page.request.get(`${BASE}/no-such-repo`);
448 expect(resp.status()).toBe(404);
449 } finally { await page.close(); }
450 });
451
452 test('non-existent issue returns 404', async () => {
453 const page = await adminCtx.newPage();
454 try {
455 const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
456 expect(resp.status()).toBe(404);
457 } finally { await page.close(); }
458 });
459
460 test('non-existent commit returns 404', async () => {
461 const page = await adminCtx.newPage();
462 try {
463 const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
464 expect(resp.status()).toBe(404);
465 } finally { await page.close(); }
466 });
467
468 test('non-existent file blob returns 404', async () => {
469 const page = await adminCtx.newPage();
470 try {
471 const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
472 expect(resp.status()).toBe(404);
473 } finally { await page.close(); }
474 });
475
476 test('non-existent patch returns 404', async () => {
477 const page = await adminCtx.newPage();
478 try {
479 const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
480 expect(resp.status()).toBe(404);
481 } finally { await page.close(); }
482 });
483
484 test('non-existent release returns 404', async () => {
485 const page = await adminCtx.newPage();
486 try {
487 const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
488 expect(resp.status()).toBe(404);
489 } finally { await page.close(); }
490 });
491});
492