From 5a24a9c70b5ae1be473d3b390e45f0a7736281d2 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 14:31:41 +0100 Subject: [PATCH] test: add E2E tests for admin pages --- tests/e2e/admin.spec.js | 238 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/setup_db.py | 22 ++++ 2 files changed, 260 insertions(+) create mode 100644 tests/e2e/admin.spec.js diff --git a/tests/e2e/admin.spec.js b/tests/e2e/admin.spec.js new file mode 100644 index 0000000..24971a7 --- /dev/null +++ b/tests/e2e/admin.spec.js @@ -0,0 +1,238 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + +/** Log in as the admin user and land on /manage/credentials. */ +async function loginAsAdmin(page) { + await page.goto('/login'); + await page.fill('#username', fixtures.admin_username); + await page.fill('#password', fixtures.admin_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); +} + +/** Log in as a regular (non-admin) user. */ +async function loginAsRegularUser(page) { + await page.goto('/login'); + await page.fill('#username', fixtures.login_username); + await page.fill('#password', fixtures.login_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); +} + +test.describe('Admin pages', () => { + // --------------------------------------------------------------- + // 1 & 2. Auth guards + // --------------------------------------------------------------- + test.describe('Auth guard', () => { + test('unauthenticated user visiting /admin/users is redirected to /login', async ({ page }) => { + await page.goto('/admin/users'); + await page.waitForURL('**/login', { timeout: 5000 }); + expect(page.url()).toContain('/login'); + }); + + test('non-admin logged-in user gets 403', async ({ page }) => { + await loginAsRegularUser(page); + const response = await page.goto('/admin/users'); + expect(response.status()).toBe(403); + }); + }); + + // --------------------------------------------------------------- + // 3 & 4 & 5. User list page + // --------------------------------------------------------------- + test.describe('User list page', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + }); + + test('has correct page structure', async ({ page }) => { + await expect(page.locator('h1')).toHaveText('Users'); + await expect(page.locator('.admin-table thead th')).toHaveCount(6); + await expect(page.locator('input[name="q"]')).toBeVisible(); + await expect(page.locator('form[hx-post="/admin/invite"]')).toBeVisible(); + }); + + test('table headers are correct', async ({ page }) => { + const headers = page.locator('.admin-table thead th'); + await expect(headers.nth(0)).toHaveText('Username'); + await expect(headers.nth(1)).toHaveText('Name'); + await expect(headers.nth(2)).toHaveText('Email'); + await expect(headers.nth(3)).toHaveText('Groups'); + await expect(headers.nth(4)).toHaveText('Status'); + await expect(headers.nth(5)).toHaveText('Created'); + }); + + test('shows seeded users', async ({ page }) => { + const tableBody = page.locator('#user-table-body'); + await expect(tableBody).toContainText('testuser'); + await expect(tableBody).toContainText('adminuser'); + }); + + test('search filters results', async ({ page }) => { + const searchInput = page.locator('input[name="q"]'); + await searchInput.fill('admin'); + // Wait for htmx debounce (300ms) and response + await expect(page.locator('#user-table-body')).toContainText('adminuser', { timeout: 5000 }); + // Other users should be filtered out + await expect(page.locator('#user-table-body')).not.toContainText('testuser', { timeout: 3000 }); + }); + }); + + // --------------------------------------------------------------- + // 6. User detail page structure + // --------------------------------------------------------------- + test.describe('User detail page', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + }); + + test('clicking a user link shows detail page', async ({ page }) => { + await page.click(`a:has-text("adminuser")`); + await page.waitForURL('**/admin/users/**', { timeout: 5000 }); + + await expect(page.locator('h1')).toHaveText('adminuser'); + // Profile section + await expect(page.locator('h2:has-text("Profile")')).toBeVisible(); + await expect(page.locator('#given_name')).toBeVisible(); + // Groups section + await expect(page.locator('h2:has-text("Groups")')).toBeVisible(); + await expect(page.locator('#groups')).toBeVisible(); + // Credentials section + await expect(page.locator('h2:has-text("Credentials")')).toBeVisible(); + // Actions section + await expect(page.locator('h2:has-text("Actions")')).toBeVisible(); + }); + }); + + // --------------------------------------------------------------- + // 7. Profile update + // --------------------------------------------------------------- + test.describe('Profile update', () => { + test('fill in profile fields and save', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.fill('#given_name', 'Updated'); + await page.fill('#family_name', 'Name'); + await page.fill('#email', 'updated@example.com'); + + await page.click('section:has(h2:has-text("Profile")) button[type="submit"]'); + + const status = page.locator('#profile-status [role="status"]'); + await expect(status).toBeVisible({ timeout: 5000 }); + await expect(status).toContainText('Profile updated'); + }); + }); + + // --------------------------------------------------------------- + // 8. Groups update + // --------------------------------------------------------------- + test.describe('Groups update', () => { + test('change groups and save', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.fill('#groups', 'admin, users, editors'); + await page.click('section:has(h2:has-text("Groups")) button[type="submit"]'); + + const status = page.locator('#groups-status [role="status"]'); + await expect(status).toBeVisible({ timeout: 5000 }); + await expect(status).toContainText('Groups updated'); + }); + }); + + // --------------------------------------------------------------- + // 9. Activate/deactivate toggle + // --------------------------------------------------------------- + test.describe('Activate/deactivate toggle', () => { + test('deactivate then activate user', async ({ page }) => { + await loginAsAdmin(page); + // Use a non-admin user for this test + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + // Click deactivate + await page.click('button:has-text("Deactivate user")'); + const deactivatedStatus = page.locator('#actions-section [role="status"]'); + await expect(deactivatedStatus).toBeVisible({ timeout: 5000 }); + await expect(deactivatedStatus).toContainText('User deactivated'); + + // Now an activate button should appear + await expect(page.locator('button:has-text("Activate user")')).toBeVisible(); + + // Click activate + await page.click('button:has-text("Activate user")'); + const activatedStatus = page.locator('#actions-section [role="status"]'); + await expect(activatedStatus).toBeVisible({ timeout: 5000 }); + await expect(activatedStatus).toContainText('User activated'); + + // Deactivate button should reappear + await expect(page.locator('button:has-text("Deactivate user")')).toBeVisible(); + }); + }); + + // --------------------------------------------------------------- + // 10. Create invite from user list + // --------------------------------------------------------------- + test.describe('Create invite', () => { + test('fill username and submit to get invite URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/users'); + + await page.fill('form[hx-post="/admin/invite"] input[name="username"]', 'inviteduser'); + await page.click('form[hx-post="/admin/invite"] button[type="submit"]'); + + const inviteStatus = page.locator('#invite-status'); + await expect(inviteStatus.locator('.invite-url')).toBeVisible({ timeout: 5000 }); + const inviteUrl = await inviteStatus.locator('.invite-url').textContent(); + expect(inviteUrl).toContain('/register/'); + }); + }); + + // --------------------------------------------------------------- + // 11. Re-invite from user detail + // --------------------------------------------------------------- + test.describe('Re-invite from user detail', () => { + test('generate invite link from user detail page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`/admin/users/${fixtures.admin_userid}`); + + await page.click('button:has-text("Generate invite link")'); + + const inviteResult = page.locator('#invite-result'); + await expect(inviteResult.locator('.invite-url')).toBeVisible({ timeout: 5000 }); + const inviteUrl = await inviteResult.locator('.invite-url').textContent(); + expect(inviteUrl).toContain('/register/'); + }); + }); + + // --------------------------------------------------------------- + // 12. Delete user + // --------------------------------------------------------------- + test.describe('Delete user', () => { + test('delete a user and verify redirect to user list', async ({ page }) => { + await loginAsAdmin(page); + + // Use a disposable user seeded specifically for this test + await page.goto(`/admin/users/${fixtures.disposable_userid}`); + await expect(page.locator('h1')).toHaveText('disposableuser'); + + // Set up dialog handler before clicking delete + page.on('dialog', async (dialog) => { + await dialog.accept(); + }); + + await page.click('button:has-text("Delete user")'); + + // htmx processes the HX-Redirect header and navigates to /admin/users + await page.waitForURL('**/admin/users', { timeout: 5000 }); + await expect(page.locator('h1')).toHaveText('Users'); + + // The deleted user should no longer appear in the list + await expect(page.locator('#user-table-body')).not.toContainText('disposableuser'); + }); + }); +}); diff --git a/tests/e2e/setup_db.py b/tests/e2e/setup_db.py index b654054..bce3821 100644 --- a/tests/e2e/setup_db.py +++ b/tests/e2e/setup_db.py @@ -100,6 +100,28 @@ async def seed() -> None: result["profile_username"] = "profileuser" result["profile_password"] = "profilepass123" + # 6. Admin user for admin page tests + admin_user = User( + userid="test-user-05", + username="adminuser", + given_name="Admin", + family_name="User", + email="admin@example.com", + groups=["admin", "users"], + ) + await user_repo.create(admin_user) + admin_password_hash = password_service.hash("adminpass123") + await cred_repo.create_password(PasswordCredential(user_id=admin_user.userid, password_hash=admin_password_hash)) + result["admin_username"] = "adminuser" + result["admin_password"] = "adminpass123" + result["admin_userid"] = "test-user-05" + + # 7. Disposable user for admin delete test (not used by any other tests) + disposable_user = User(userid="test-user-06", username="disposableuser", groups=["users"]) + await user_repo.create(disposable_user) + result["disposable_userid"] = "test-user-06" + result["disposable_username"] = "disposableuser" + await db.commit() await db.close() print(json.dumps(result))