import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { chromium } from 'playwright'; import type { Browser, BrowserContext } from 'playwright'; import { rmSync, readFileSync } from 'node:fs'; import { spawnSync as nodeSpawnSync } from 'node:child_process'; import { BASE, ADMIN_PASS, setupTestEnv, spawnServer, killServer, login, seedRepo, } from './helpers.ts'; let browser: Browser; let server: Awaited>; beforeAll(async () => { await setupTestEnv(); server = await spawnServer(); browser = await chromium.launch(); // Register alice const regCtx = await browser.newContext(); const regPage = await regCtx.newPage(); try { await regPage.goto(`${BASE}/register`); await regPage.fill('[name=username]', 'alice'); await regPage.fill('[name=password]', 'password123'); await regPage.fill('[name=password2]', 'password123'); await regPage.click('button[type=submit]'); await regPage.waitForURL(BASE + '/'); } finally { await regCtx.close(); } // Create my-repo (needed for repo deletion non-admin test) const adminCtx = await browser.newContext(); const adminPage = await adminCtx.newPage(); try { await login(adminPage); await adminPage.goto(`${BASE}/new`); await adminPage.fill('[name=name]', 'my-repo'); await adminPage.click('form[action="/new"] button[type=submit]'); await adminPage.waitForURL(`${BASE}/my-repo`); } finally { await adminCtx.close(); } await seedRepo('my-repo'); }); afterAll(async () => { await browser.close(); await killServer(server); }); async function loggedInContext(username = 'admin', password = ADMIN_PASS) { const ctx = await browser.newContext(); const page = await ctx.newPage(); await login(page, username, password); await page.close(); return ctx; } // ─── Settings ───────────────────────────────────────────────────────────────── describe('settings', () => { let adminCtx: BrowserContext; let aliceCtx: BrowserContext; // Public key generated once in beforeAll, reused across SSH key tests let testPubKey: string; beforeAll(async () => { adminCtx = await loggedInContext(); aliceCtx = await loggedInContext('alice', 'password123'); // Generate a throwaway ed25519 key for SSH key tests. const keyPath = '/tmp/hf-e2e-sshkey'; rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true }); nodeSpawnSync( 'ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'], { stdio: 'ignore' }, ); testPubKey = readFileSync(`${keyPath}.pub`, 'utf-8').trim(); rmSync(keyPath, { force: true }); rmSync(`${keyPath}.pub`, { force: true }); }); afterAll(async () => { await adminCtx.close(); await aliceCtx.close(); }); test('settings page requires auth', async () => { const ctx = await browser.newContext(); const page = await ctx.newPage(); try { await page.goto(`${BASE}/settings`); expect(page.url()).toContain('/login'); } finally { await ctx.close(); } }); test('settings page loads for logged-in user', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); expect(await page.locator('h1.page-title').textContent()).toBe('Settings'); } finally { await page.close(); } }); // ── Password ────────────────────────────────────────────────────────────── test('password change with mismatched passwords shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=new_password]', 'newpass123'); await page.fill('[name=confirm_password]', 'different456'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('password change with wrong current password shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=current_password]', 'wrongpassword'); await page.fill('[name=new_password]', 'newpass123'); await page.fill('[name=confirm_password]', 'newpass123'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('password change too short shows error', async () => { const page = await aliceCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('[name=current_password]', 'password123'); await page.fill('[name=new_password]', 'short'); await page.fill('[name=confirm_password]', 'short'); await page.click('form[action="/settings/password"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); // ── SSH keys ────────────────────────────────────────────────────────────── test('add SSH key with unsupported key type shows error', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'Bad key'); await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test'); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('add valid SSH key shows success and key appears in list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'My Laptop'); await page.fill('#ssh_public_key', testPubKey); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=ssh_key_added'); await page.goto(`${BASE}/settings`); expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop'); } finally { await page.close(); } }); test('add duplicate SSH key shows error', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#ssh_key_name', 'Duplicate'); await page.fill('#ssh_public_key', testPubKey); await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('delete SSH key removes it from list', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); // Click the Remove button for the key added above await page.click('form[action="/settings/ssh-keys/delete"] button'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=ssh_key_deleted'); await page.goto(`${BASE}/settings`); expect(await page.locator('.ssh-key-name').count()).toBe(0); } finally { await page.close(); } }); // ── Admin user management ──────────────────────────────────────────────── test('admin can create a new user account', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#new_username', 'charlie'); await page.fill('#new_user_password', 'charliepw1'); await page.click('form[action="/admin/users"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('success=user_created'); } finally { await page.close(); } }); test('admin cannot create duplicate username', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); await page.fill('#new_username', 'charlie'); await page.fill('#new_user_password', 'charliepw1'); await page.click('form[action="/admin/users"] button[type=submit]'); await page.waitForURL(/\/settings/); expect(page.url()).toContain('error'); } finally { await page.close(); } }); test('admin cannot create user with invalid username characters', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users`, { form: { username: 'bad user!', password: 'password123' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const location = resp.headers()['location'] ?? ''; expect(location).toContain('error'); }); test('non-admin gets 403 when creating user', async () => { const resp = await aliceCtx.request.post(`${BASE}/admin/users`, { form: { username: 'hacker', password: 'password123' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); }); test('admin can delete user account', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { form: { username: 'charlie' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('success=user_deleted'); }); test('admin cannot delete the admin account', async () => { const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { form: { username: 'admin' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toContain('error'); }); test('settings page has no git identity section', async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/settings`); expect(await page.locator('text=Git Identity').count()).toBe(0); expect(await page.locator('[name=git_name]').count()).toBe(0); expect(await page.locator('[name=git_email]').count()).toBe(0); } finally { await page.close(); } }); test('git identity route no longer exists', async () => { const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { form: { git_name: 'Test', git_email: 'test@example.com' }, maxRedirects: 0, }); expect(resp.status()).toBe(404); }); }); // ─── Repository deletion ────────────────────────────────────────────────────── describe('repository deletion', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); // Create a repo to delete const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'deleteme-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/deleteme-repo`); } finally { await page.close(); } }); afterAll(async () => { await adminCtx.close(); }); test('admin can delete repository', async () => { const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(resp.headers()['location']).toBe('/'); }); test('deleted repository returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/deleteme-repo`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('deleted repository no longer appears in list', async () => { const page = await adminCtx.newPage(); try { await page.goto(BASE); expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo'); } finally { await page.close(); } }); test('non-admin cannot delete repository', async () => { const aliceCtx = await loggedInContext('alice', 'password123'); const page = await aliceCtx.newPage(); try { const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, { maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await page.close(); await aliceCtx.close(); } }); }); // ─── Repository rename ─────────────────────────────────────────────────────── describe('repository rename', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'renameme-repo'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/renameme-repo`); await page.goto(`${BASE}/new`); await page.fill('[name=name]', 'rename-other'); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/rename-other`); } finally { await page.close(); } }); afterAll(async () => { await adminCtx.close(); }); test('rejects invalid name', async () => { const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, { form: { new_name: 'bad name' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const loc = resp.headers()['location']!; expect(loc).toContain('/renameme-repo/settings?error='); expect(decodeURIComponent(loc)).toContain('Invalid'); }); test('rejects no-op rename', async () => { const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, { form: { new_name: 'renameme-repo' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name'); }); test('rejects duplicate name', async () => { const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, { form: { new_name: 'rename-other' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken'); }); test('non-admin cannot rename', async () => { const aliceCtx = await loggedInContext('alice', 'password123'); try { const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, { form: { new_name: 'hijack' }, maxRedirects: 0, }); expect(resp.status()).toBe(403); } finally { await aliceCtx.close(); } }); test('admin can rename repository', async () => { const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, { form: { new_name: 'renamed-repo' }, maxRedirects: 0, }); expect(resp.status()).toBe(302); const loc = resp.headers()['location']!; expect(loc).toContain('/renamed-repo/settings?success='); const old = await adminCtx.request.get(`${BASE}/renameme-repo`); expect(old.status()).toBe(404); const next = await adminCtx.request.get(`${BASE}/renamed-repo`); expect(next.status()).toBe(200); }); }); // ─── 404 handling ───────────────────────────────────────────────────────────── describe('404 handling', () => { let adminCtx: BrowserContext; beforeAll(async () => { adminCtx = await loggedInContext(); }); afterAll(async () => { await adminCtx.close(); }); test('non-existent repository returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/no-such-repo`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent issue returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/issues/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent commit returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent file blob returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent patch returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/patches/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); test('non-existent release returns 404', async () => { const page = await adminCtx.newPage(); try { const resp = await page.request.get(`${BASE}/my-repo/releases/99999`); expect(resp.status()).toBe(404); } finally { await page.close(); } }); });