Add 7 new e2e tests verifying profile form validation in both manage and admin UIs: invalid phone number, phone normalization, E.164 hint attributes, and admin-side email/phone/picture URL validation errors. Fix 3 pre-existing test failures: - Replace invalid seeded phone number (+1234567890) with valid E.164 (+12025551234) that was causing profile update tests to fail - Update email validation error assertion to match actual pydantic message (value_error type uses raw message, not label-prefixed)
298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
// @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. Profile validation
|
|
// ---------------------------------------------------------------
|
|
test.describe('Profile validation', () => {
|
|
test('invalid email shows error', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
|
|
|
// Bypass HTML5 email validation so the form can submit
|
|
await page.locator('#email').evaluate(el => el.type = 'text');
|
|
await page.fill('#email', 'not-an-email');
|
|
|
|
await page.click('section:has(h2:has-text("Profile")) button[type="submit"]');
|
|
|
|
const alert = page.locator('#profile-status [role="alert"]');
|
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
|
await expect(alert).toContainText('valid email address');
|
|
});
|
|
|
|
test('invalid phone number shows error', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
|
|
|
// Bypass HTML5 pattern validation so the form can submit
|
|
await page.locator('#phone_number').evaluate(el => el.removeAttribute('pattern'));
|
|
await page.fill('#phone_number', 'not-a-phone');
|
|
|
|
await page.click('section:has(h2:has-text("Profile")) button[type="submit"]');
|
|
|
|
const alert = page.locator('#profile-status [role="alert"]');
|
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
|
await expect(alert).toContainText('valid phone number');
|
|
});
|
|
|
|
test('invalid picture URL shows error', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
|
|
|
// Bypass HTML5 URL validation so the form can submit
|
|
await page.locator('#picture').evaluate(el => el.type = 'text');
|
|
await page.fill('#picture', 'not-a-url');
|
|
|
|
await page.click('section:has(h2:has-text("Profile")) button[type="submit"]');
|
|
|
|
const alert = page.locator('#profile-status [role="alert"]');
|
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
|
await expect(alert).toContainText('Picture URL');
|
|
});
|
|
|
|
test('phone input has E.164 hint attributes', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
|
|
|
const phoneInput = page.locator('#phone_number');
|
|
await expect(phoneInput).toHaveAttribute('type', 'tel');
|
|
await expect(phoneInput).toHaveAttribute('pattern', '\\+[0-9 ]+');
|
|
await expect(phoneInput).toHaveAttribute('title', /International format/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// 9. 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');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// 10. 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();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// 11. 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/');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// 12. 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/');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// 13. 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');
|
|
});
|
|
});
|
|
});
|