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 invalid name', async () => {
366 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
367 form: { new_name: 'bad name' },
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('Invalid');
374 });
375
376 test('rejects no-op rename', async () => {
377 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
378 form: { new_name: 'renameme-repo' },
379 maxRedirects: 0,
380 });
381 expect(resp.status()).toBe(302);
382 expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name');
383 });
384
385 test('rejects duplicate name', async () => {
386 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
387 form: { new_name: 'rename-other' },
388 maxRedirects: 0,
389 });
390 expect(resp.status()).toBe(302);
391 expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken');
392 });
393
394 test('non-admin cannot rename', async () => {
395 const aliceCtx = await loggedInContext('alice', 'password123');
396 try {
397 const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
398 form: { new_name: 'hijack' },
399 maxRedirects: 0,
400 });
401 expect(resp.status()).toBe(403);
402 } finally { await aliceCtx.close(); }
403 });
404
405 test('admin can rename repository', async () => {
406 const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
407 form: { new_name: 'renamed-repo' },
408 maxRedirects: 0,
409 });
410 expect(resp.status()).toBe(302);
411 const loc = resp.headers()['location']!;
412 expect(loc).toContain('/renamed-repo/settings?success=');
413
414 const old = await adminCtx.request.get(`${BASE}/renameme-repo`);
415 expect(old.status()).toBe(404);
416
417 const next = await adminCtx.request.get(`${BASE}/renamed-repo`);
418 expect(next.status()).toBe(200);
419 });
420});
421
422// ─── 404 handling ─────────────────────────────────────────────────────────────
423
424describe('404 handling', () => {
425 let adminCtx: BrowserContext;
426
427 beforeAll(async () => { adminCtx = await loggedInContext(); });
428 afterAll(async () => { await adminCtx.close(); });
429
430 test('non-existent repository returns 404', async () => {
431 const page = await adminCtx.newPage();
432 try {
433 const resp = await page.request.get(`${BASE}/no-such-repo`);
434 expect(resp.status()).toBe(404);
435 } finally { await page.close(); }
436 });
437
438 test('non-existent issue returns 404', async () => {
439 const page = await adminCtx.newPage();
440 try {
441 const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
442 expect(resp.status()).toBe(404);
443 } finally { await page.close(); }
444 });
445
446 test('non-existent commit returns 404', async () => {
447 const page = await adminCtx.newPage();
448 try {
449 const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
450 expect(resp.status()).toBe(404);
451 } finally { await page.close(); }
452 });
453
454 test('non-existent file blob returns 404', async () => {
455 const page = await adminCtx.newPage();
456 try {
457 const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
458 expect(resp.status()).toBe(404);
459 } finally { await page.close(); }
460 });
461
462 test('non-existent patch returns 404', async () => {
463 const page = await adminCtx.newPage();
464 try {
465 const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
466 expect(resp.status()).toBe(404);
467 } finally { await page.close(); }
468 });
469
470 test('non-existent release returns 404', async () => {
471 const page = await adminCtx.newPage();
472 try {
473 const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
474 expect(resp.status()).toBe(404);
475 } finally { await page.close(); }
476 });
477});
478