/** * User Management E2E Tests * * Tests the user management functionality including: * - User list display and status badges * - Invite user workflow (modal, validation, role selection) * - Permission management (mode, permitted hosts) * - User actions (enable/disable, delete, role changes) * - Accessibility and security compliance * * @see /projects/Charon/docs/plans/phase4-settings-plan.md - Section 3.4 * @see /projects/Charon/frontend/src/pages/UsersPage.tsx */ import { test, expect, loginUser, logoutUser, TEST_PASSWORD } from '../fixtures/auth-fixtures'; import { waitForLoadingComplete, waitForToast, waitForModal, waitForAPIResponse, } from '../utils/wait-helpers'; import { getRowScopedButton, getRowScopedIconButton, clickSwitch } from '../utils/ui-helpers'; test.describe('User Management', () => { test.beforeEach(async ({ page, adminUser }) => { await loginUser(page, adminUser); await waitForLoadingComplete(page); await page.goto('/users'); await waitForLoadingComplete(page); // Wait for page to stabilize - needed for parallel test runs await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); }); test.describe('User List', () => { /** * Test: User list displays correctly * Priority: P0 */ test('should display user list', async ({ page }) => { // Triple timeouts for CI stability - page rendering can be slow under load test.slow(); await test.step('Verify page URL and heading', async () => { await expect(page).toHaveURL(/\/users/); // Use name-scoped locator to avoid strict mode violation — the settings // layout renders a second h1 ("Settings") alongside the content heading. const heading = page.getByRole('heading', { name: 'User Management' }); await expect(heading).toBeVisible({ timeout: 10000 }); }); await test.step('Verify user table is visible', async () => { const table = page.getByRole('table'); await expect(table).toBeVisible(); }); await test.step('Verify table headers exist', async () => { // Only check for headers that actually exist in current UI const headers = page.getByRole('columnheader'); const headerCount = await headers.count(); expect(headerCount).toBeGreaterThan(0); }); await test.step('Verify at least one user row exists', async () => { const rows = page.getByRole('row'); // Header row + at least 1 data row (the admin user created by fixture) const rowCount = await rows.count(); expect(rowCount).toBeGreaterThanOrEqual(2); }); }); /** * Test: User status badges display correctly * Priority: P1 */ test('should show user status badges', async ({ page }) => { // SKIP: UI feature not yet implemented. // TODO: Re-enable when user status badges are added to the UI. await test.step('Verify active status has correct styling', async () => { const activeStatus = page.locator('span').filter({ hasText: /^active$/i, }); // Wait for active status badge to be visible first await expect(activeStatus.first()).toBeVisible({ timeout: 10000 }); // Use web-first assertion that auto-retries for CSS class check // Should have green color indicator (Tailwind uses text-green-400 or similar) await expect(activeStatus.first()).toHaveClass(/green|success/, { timeout: 5000 }); }); }); /** * Test: Role badges display correctly * Priority: P1 */ test('should display role badges', async ({ page }) => { await test.step('Verify admin role badge', async () => { const adminBadge = page.locator('span').filter({ hasText: /^admin$/i, }); await expect(adminBadge.first()).toBeVisible(); }); await test.step('Verify role badges have distinct styling', async () => { const adminBadge = page.locator('span').filter({ hasText: /^admin$/i, }).first(); // Admin badge should have purple/distinct color const hasDistinctColor = await adminBadge.evaluate((el) => { const classList = el.className; return ( classList.includes('purple') || classList.includes('blue') || classList.includes('rounded') ); }); expect(hasDistinctColor).toBeTruthy(); }); }); /** * Test: Last login time displays * Priority: P2 */ test('should show last login time', async ({ page }) => { await test.step('Check for login time information', async () => { // The table may show last login in a column or tooltip // Looking for date/time patterns or "Last login" text const loginInfo = page.getByText(/last.*login|ago|never/i); // This is optional - some implementations may not show last login const hasLoginInfo = await loginInfo.first().isVisible({ timeout: 3000 }).catch(() => false); // Skip if not implemented if (!hasLoginInfo) { return; } await expect(loginInfo.first()).toBeVisible(); }); }); /** * Test: Pending invite status displays * Priority: P1 */ // Skip: Complex flow that creates invite through UI and checks status - timing sensitive test('should show pending invite status', async ({ page, testData }) => { // First create a pending invite const inviteEmail = `pending-${Date.now()}@test.local`; await test.step('Create a pending invite via API', async () => { // Use the invite button to create a pending user const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); // Fill and submit the invite form const emailInput = page.getByPlaceholder(/user@example/i); await expect(emailInput).toBeVisible(); await emailInput.fill(inviteEmail); // Scope to dialog to avoid strict mode violation with "Resend Invite" button const sendButton = page.getByRole('dialog') .getByRole('button', { name: /send.*invite/i }) .first(); await sendButton.click(); await expect(page.getByRole('dialog').getByRole('button', { name: /done/i }).first()).toBeVisible({ timeout: 10000 }); // Close the modal - scope to dialog to avoid strict mode violation with Toast close buttons const closeButton = page.getByRole('dialog') .getByRole('button', { name: /done|close|×/i }) .first(); if (await closeButton.isVisible()) { await closeButton.click(); } }); await test.step('Verify pending status appears in list', async () => { // Reload to see the new user await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Find the pending status indicator const pendingStatus = page.locator('span').filter({ hasText: /pending/i, }); await expect(pendingStatus.first()).toBeVisible({ timeout: 5000 }); // Verify it has clock icon or yellow styling const row = page.getByRole('row').filter({ hasText: inviteEmail, }); await expect(row).toBeVisible(); }); }); }); test.describe('Invite User', () => { /** * Test: Invite modal opens correctly * Priority: P0 */ test('should open invite user modal', async ({ page }) => { await test.step('Click invite user button', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await expect(inviteButton).toBeVisible(); await inviteButton.click(); }); await test.step('Verify modal is visible', async () => { // Wait for modal to appear (uses role="dialog") const modal = page.getByRole('dialog'); await expect(modal).toBeVisible(); }); await test.step('Verify modal contains required fields', async () => { // Input uses placeholder="user@example.com" const emailInput = page.getByPlaceholder(/user@example/i); const roleSelect = page.locator('select').filter({ has: page.locator('option', { hasText: /user|admin/i }), }); await expect(emailInput).toBeVisible(); await expect(roleSelect.first()).toBeVisible(); }); await test.step('Verify cancel button exists', async () => { const cancelButton = page.getByRole('button', { name: /cancel/i }); await expect(cancelButton).toBeVisible(); }); }); /** * Test: Send invite with valid email * Priority: P0 */ test('should send invite with valid email', async ({ page }) => { const testEmail = `invite-test-${Date.now()}@test.local`; await test.step('Open invite modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); }); await test.step('Fill email field', async () => { const emailInput = page.getByPlaceholder(/user@example/i); await emailInput.fill(testEmail); await expect(emailInput).toHaveValue(testEmail); }); await test.step('Submit invite', async () => { // Scope to dialog to avoid strict mode violation with "Resend Invite" button const sendButton = page.getByRole('dialog') .getByRole('button', { name: /send.*invite/i }) .first(); await sendButton.click(); }); await test.step('Verify success message', async () => { // Should show success state in modal or toast const successMessage = page.getByText(/success|invite.*sent|invite.*created/i); await expect(successMessage.first()).toBeVisible({ timeout: 10000 }); }); }); /** * Test: Validate email format * Priority: P0 */ test('should validate email format', async ({ page }) => { await test.step('Open invite modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); }); await test.step('Enter invalid email', async () => { const emailInput = page.getByPlaceholder(/user@example/i).first(); await emailInput.fill('not-a-valid-email'); }); await test.step('Verify send button is disabled or error shown', async () => { // Scope to dialog to avoid strict mode violation const sendButton = page.getByRole('dialog') .getByRole('button', { name: /send.*invite/i }) .first(); // Either button is disabled or clicking shows error const isDisabled = await sendButton.isDisabled(); if (!isDisabled) { await sendButton.click(); const errorMessage = page.getByText(/invalid.*email|email.*invalid|valid.*email/i).first(); await expect(errorMessage).toBeVisible({ timeout: 5000 }); } else { expect(isDisabled).toBeTruthy(); } }); }); /** * Test: Select user role * Priority: P0 */ test('should select user role', async ({ page }) => { await test.step('Open invite modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); }); await test.step('Select admin role', async () => { const roleSelect = page.locator('select').first(); await roleSelect.selectOption('admin'); }); await test.step('Verify admin is selected', async () => { const roleSelect = page.locator('select').first(); const selectedValue = await roleSelect.inputValue(); expect(selectedValue).toBe('admin'); }); await test.step('Select user role', async () => { const roleSelect = page.locator('select').first(); await roleSelect.selectOption('user'); }); await test.step('Verify user is selected', async () => { const roleSelect = page.locator('select').first(); const selectedValue = await roleSelect.inputValue(); expect(selectedValue).toBe('user'); }); }); /** * Test: Configure permission mode * Priority: P0 */ test('should configure permission mode', async ({ page }) => { await test.step('Open invite modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); }); await test.step('Ensure user role is selected to show permission options', async () => { const roleSelect = page.locator('select').first(); await roleSelect.selectOption('user'); }); await test.step('Find and change permission mode', async () => { // Permission mode select should appear for non-admin users const permissionSelect = page.locator('select').filter({ has: page.locator('option', { hasText: /allow.*all|deny.*all|whitelist|blacklist/i }), }); await expect(permissionSelect.first()).toBeVisible(); await permissionSelect.first().selectOption({ index: 1 }); }); await test.step('Verify permission mode changed', async () => { const permissionSelect = page.locator('select').nth(1); const selectedValue = await permissionSelect.inputValue(); expect(selectedValue).toBeTruthy(); }); }); /** * Test: Select permitted hosts * Priority: P1 */ test('should select permitted hosts', async ({ page }) => { await test.step('Open invite modal and select user role', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); const roleSelect = page.locator('select').first(); await roleSelect.selectOption('user'); }); await test.step('Find hosts list', async () => { // Hosts are displayed in a scrollable list with checkboxes const hostsList = page.locator('[class*="overflow"]').filter({ has: page.locator('input[type="checkbox"]'), }); // Skip if no proxy hosts exist const hasHosts = await hostsList.first().isVisible({ timeout: 3000 }).catch(() => false); if (!hasHosts) { // Check for "no proxy hosts" message const noHostsMessage = page.getByText(/no.*proxy.*host/i); await expect(noHostsMessage).toBeVisible(); return; } }); await test.step('Select a host', async () => { const hostCheckbox = page.locator('input[type="checkbox"]').first(); if (await hostCheckbox.isVisible()) { await hostCheckbox.check(); await expect(hostCheckbox).toBeChecked(); } }); }); /** * Test: Show invite URL preview * Priority: P1 */ test('should show invite URL preview', async ({ page }) => { const inviteModal = await test.step('Open invite modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); return await waitForModal(page, /invite.*user/i); }); await test.step('Enter valid email', async () => { // Debounced API call triggers invite URL preview const previewResponsePromise = waitForAPIResponse( page, /\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/, { status: 200 } ); const emailInput = inviteModal.getByPlaceholder(/user@example/i); await emailInput.fill('preview-test@example.com'); await previewResponsePromise; }); await test.step('Wait for URL preview to appear', async () => { // When app.public_url is not configured, the backend returns an empty preview URL // and the UI shows a warning with a link to system settings. const warningAlert = inviteModal .getByRole('alert') .filter({ hasText: /application url is not configured/i }) .first(); const previewUrlText = inviteModal .locator('div.font-mono') .filter({ hasText: /accept-invite\?token=/i }) .first(); await expect(warningAlert.or(previewUrlText).first()).toBeVisible({ timeout: 5000 }); if (await warningAlert.isVisible().catch(() => false)) { const configureLink = warningAlert.getByRole('link', { name: /configure.*application url/i }); await expect(configureLink).toBeVisible(); await expect(configureLink).toHaveAttribute('href', '/settings/system'); } else { await expect(previewUrlText).toBeVisible(); } }); }); /** * Test: Copy invite link * Priority: P1 */ test('should copy invite link', async ({ page, context }, testInfo) => { // Grant clipboard permissions only on Chromium — Firefox/WebKit don't support clipboard-read/write. const browserName = testInfo.project?.name || ''; if (browserName === 'chromium') { await context.grantPermissions(['clipboard-read', 'clipboard-write']); } const testEmail = `copy-test-${Date.now()}@test.local`; await test.step('Create an invite', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); const emailInput = page.getByPlaceholder(/user@example/i); await emailInput.fill(testEmail); // Scope to dialog to avoid strict mode violation with "Resend Invite" button const sendButton = page.getByRole('dialog') .getByRole('button', { name: /send.*invite/i }) .first(); await sendButton.click(); // Wait for success state const successMessage = page.getByText(/success|created/i); await expect(successMessage.first()).toBeVisible({ timeout: 10000 }); }); await test.step('Click copy button (if invite link is shown)', async () => { // Scope to dialog to avoid strict mode with Resend/other buttons const dialog = page.getByRole('dialog'); const copyButton = dialog.getByRole('button', { name: /copy/i }).or( dialog.getByRole('button').filter({ has: dialog.locator('svg.lucide-copy') }) ); const hasCopyButton = await copyButton.first().isVisible({ timeout: 3000 }).catch(() => false); if (!hasCopyButton) { const emailSentMessage = dialog.getByText(/email.*sent|invite.*sent/i).first(); await expect(emailSentMessage).toBeVisible({ timeout: 5000 }); return; } await copyButton.first().click(); }); await test.step('Verify copy success toast when copy button is available', async () => { // Wait for the specific "copied to clipboard" toast (there may be 2 success toasts) const copiedToast = page.locator('[data-testid="toast-success"]').filter({ hasText: /copied|clipboard/i, }); const hasCopyToast = await copiedToast.isVisible({ timeout: 3000 }).catch(() => false); if (!hasCopyToast) { return; } await expect(copiedToast).toBeVisible({ timeout: 10000 }); }); await test.step('Verify clipboard contains invite link (Chromium-only); verify toast for other browsers', async () => { // WebKit/Firefox: Clipboard API throws NotAllowedError in CI // Success toast verified above is sufficient proof if (browserName !== 'chromium') { // Additional defensive check: verify invite link still visible const inviteLinkInput = page.locator('input[readonly]'); const inviteLinkVisible = await inviteLinkInput.first().isVisible({ timeout: 2000 }).catch(() => false); if (inviteLinkVisible) { await expect(inviteLinkInput.first()).toHaveValue(/accept-invite.*token=/); } return; // Skip clipboard verification on non-Chromium } // Chromium-only: Verify clipboard contents (only browser where we can reliably read clipboard in CI) // Headless Chromium in some CI environments returns empty string from clipboard API const clipboardText = await page.evaluate(async () => { try { return await navigator.clipboard.readText(); } catch { return ''; } }); if (clipboardText) { expect(clipboardText).toContain('accept-invite'); expect(clipboardText).toContain('token='); } else { // Clipboard API returned empty in headless CI — fall back to verifying the invite link input value const inviteLinkInput = page.locator('input[readonly]'); const inviteLinkVisible = await inviteLinkInput.first().isVisible({ timeout: 2000 }).catch(() => false); if (inviteLinkVisible) { await expect(inviteLinkInput.first()).toHaveValue(/accept-invite.*token=/); } } }); }); }); test.describe('Permission Management', () => { /** * Test: Open permissions modal * Priority: P0 */ test('should open permissions modal', async ({ page, testData }) => { // SKIP: Permissions button (settings icon) not yet implemented in UI // First create a regular user to test permissions const testUser = await testData.createUser({ name: 'Permission Test User', email: `perm-test-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); await test.step('Reload page to see new user', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); }); await test.step('Find and click permissions button for user', async () => { const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); const permissionsButton = userRow.getByRole('button', { name: /settings|permissions/i }).or( userRow.locator('button').filter({ has: page.locator('svg.lucide-settings') }) ); await expect(permissionsButton.first()).toBeVisible(); await permissionsButton.first().click(); }); await test.step('Verify permissions modal is visible', async () => { const modal = page.locator('[class*="fixed"]').filter({ has: page.getByRole('heading', { name: /permission|edit/i }), }); await expect(modal).toBeVisible(); }); }); /** * Test: Update permission mode * Priority: P0 */ // SKIP: Test requires PLAYWRIGHT_BASE_URL=http://localhost:8080 for cookie domain matching. // The permissions UI IS implemented (PermissionsModal in UsersPage.tsx), but TestDataManager // API calls fail with auth errors when base URL doesn't match cookie domain from auth setup. // Re-enable once CI environment consistently uses localhost:8080. test('should update permission mode', async ({ page, testData }) => { const testUser = await testData.createUser({ name: 'Permission Mode Test', email: `perm-mode-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); await test.step('Navigate to users page and find created user', async () => { // Navigate explicitly to ensure we're on the users page await page.goto('/users'); await waitForLoadingComplete(page); // Reload to ensure newly created user is in the query cache await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for table to be visible const table = page.getByRole('table'); await expect(table).toBeVisible({ timeout: 10000 }); // Find the user row using name match (more reliable than email which may be truncated) const userRow = page.getByRole('row').filter({ hasText: 'Permission Mode Test', }); await expect(userRow).toBeVisible({ timeout: 10000 }); // Find the permissions button using aria-label which contains "permissions" (case-insensitive) const permissionsButton = userRow.getByRole('button', { name: /permissions/i }); await expect(permissionsButton).toBeVisible({ timeout: 5000 }); await permissionsButton.click(); // Wait for modal dialog to be fully visible (title contains "permissions") await waitForModal(page, /permissions/i); }); await test.step('Change permission mode', async () => { // The modal uses role="dialog", find select within it const modal = page.locator('[role="dialog"]'); await expect(modal).toBeVisible({ timeout: 5000 }); const permissionSelect = modal.locator('select').first(); await expect(permissionSelect).toBeVisible({ timeout: 5000 }); // Toggle between modes const currentValue = await permissionSelect.inputValue(); const newValue = currentValue === 'allow_all' ? 'deny_all' : 'allow_all'; await permissionSelect.selectOption(newValue); }); await test.step('Save changes', async () => { const modal = page.locator('[role="dialog"]'); const saveButton = modal.getByRole('button', { name: /save/i }); await expect(saveButton).toBeVisible(); await expect(saveButton).toBeEnabled(); // Use Promise.all to set up response listener BEFORE clicking await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/permissions') && resp.request().method() === 'PUT' ), saveButton.click(), ]); }); await test.step('Verify success toast', async () => { await waitForToast(page, /updated|saved|success/i, { type: 'success' }); }); }); /** * Test: Add permitted hosts * Priority: P0 */ test('should add permitted hosts', async ({ page, testData }) => { // SKIP: Depends on settings (permissions) button which is not yet implemented const testUser = await testData.createUser({ name: 'Add Hosts Test', email: `add-hosts-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); const permissionsModal = await test.step('Open permissions modal', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); const permissionsButton = userRow.locator('button').filter({ has: page.locator('svg.lucide-settings'), }); await permissionsButton.first().click(); return await waitForModal(page, /permissions/i); }); await test.step('Check a host to add', async () => { const hostCheckboxes = permissionsModal.locator('input[type="checkbox"]'); const count = await hostCheckboxes.count(); if (count === 0) { // No hosts to add - return return; } const firstCheckbox = hostCheckboxes.first(); if (!(await firstCheckbox.isChecked())) { await firstCheckbox.check(); } await expect(firstCheckbox).toBeChecked(); }); await test.step('Save changes', async () => { const saveButton = permissionsModal.getByRole('button', { name: /save/i }); await saveButton.click(); }); await test.step('Verify success', async () => { await waitForToast(page, /updated|saved|success/i, { type: 'success' }); }); }); /** * Test: Remove permitted hosts * Priority: P1 */ test('should remove permitted hosts', async ({ page, testData }) => { const testUser = await testData.createUser({ name: 'Remove Hosts Test', email: `remove-hosts-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); const permissionsModal = await test.step('Open permissions modal', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); const permissionsButton = userRow.locator('button').filter({ has: page.locator('svg.lucide-settings'), }); await permissionsButton.first().click(); return await waitForModal(page, /permissions/i); }); await test.step('Uncheck a checked host', async () => { const hostCheckboxes = permissionsModal.locator('input[type="checkbox"]'); const count = await hostCheckboxes.count(); if (count === 0) { return; } // First check a box, then uncheck it const firstCheckbox = hostCheckboxes.first(); // Wait for checkbox to be enabled (may be disabled during loading) await expect(firstCheckbox).toBeEnabled({ timeout: 5000 }); await firstCheckbox.check(); await expect(firstCheckbox).toBeChecked(); await firstCheckbox.uncheck(); await expect(firstCheckbox).not.toBeChecked(); }); await test.step('Save changes', async () => { const saveButton = permissionsModal.getByRole('button', { name: /save/i }); await saveButton.click(); }); await test.step('Verify success', async () => { await waitForToast(page, /updated|saved|success/i, { type: 'success' }); }); }); /** * Test: Save permission changes * Priority: P0 */ test('should save permission changes', async ({ page, testData }) => { // SKIP: Depends on settings (permissions) button which is not yet implemented const testUser = await testData.createUser({ name: 'Save Perm Test', email: `save-perm-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); await test.step('Open permissions modal', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); const permissionsButton = userRow.locator('button').filter({ has: page.locator('svg.lucide-settings'), }); await permissionsButton.first().click(); }); await test.step('Make a change', async () => { const permissionSelect = page.locator('select').first(); await permissionSelect.selectOption({ index: 1 }); }); await test.step('Click save button', async () => { const saveButton = page.getByRole('button', { name: /save/i }); await expect(saveButton).toBeEnabled(); await saveButton.click(); }); await test.step('Verify modal closes and success toast appears', async () => { await waitForToast(page, /updated|saved|success/i, { type: 'success' }); // Modal should close const modal = page.locator('[class*="fixed"]').filter({ has: page.getByRole('heading', { name: /permission/i }), }); await expect(modal).not.toBeVisible({ timeout: 5000 }); }); }); }); test.describe('User Actions', () => { /** * Test: Enable/disable user * Priority: P0 */ // SKIPPED: TestDataManager API calls fail with "Admin access required" because // auth cookies don't propagate when cookie domain doesn't match the test URL. // Requires PLAYWRIGHT_BASE_URL=http://localhost:8080 to be set for proper auth. // See: TestDataManager uses fetch() which needs matching cookie domain. test('should enable/disable user', async ({ page, testData }) => { const testUser = await testData.createUser({ name: 'Toggle Enable Test', email: `toggle-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); await test.step('Reload to see new user', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for table to have data await page.waitForSelector('table tbody tr', { timeout: 10000 }); }); await test.step('Find user row and toggle switch', async () => { // Look for the row by name instead of email (emails may be truncated in display) const userRow = page.getByRole('row').filter({ hasText: 'Toggle Enable Test', }); await expect(userRow).toBeVisible({ timeout: 10000 }); // The Switch component uses an input[type=checkbox], not role="switch" const enableSwitch = userRow.getByRole('checkbox'); await expect(enableSwitch).toBeVisible(); const initialState = await enableSwitch.isChecked(); // The checkbox is sr-only, click the parent label container await clickSwitch(enableSwitch); // Wait for API response await page.waitForTimeout(500); const newState = await enableSwitch.isChecked(); expect(newState).not.toBe(initialState); }); await test.step('Verify success toast', async () => { await waitForToast(page, /updated|success/i, { type: 'success' }); }); }); /** * Test: Change user role * Priority: P0 */ test('should change user role', async ({ page, testData }) => { // SKIP: Role badge selector not yet implemented in UI // This test may require additional UI - some implementations allow role change inline // For now, we verify the role badge is displayed correctly await test.step('Verify role can be identified in the list', async () => { const adminRow = page.getByRole('row').filter({ has: page.locator('span').filter({ hasText: /^admin$/i }), }); await expect(adminRow.first()).toBeVisible(); }); // Note: If inline role change is available, additional steps would be added here }); /** * Test: Delete user with confirmation * Priority: P0 */ test('should delete user with confirmation', async ({ page, testData }) => { // SKIP: Delete button (trash icon) not yet implemented in UI const testUser = await testData.createUser({ name: 'Delete Test User', email: `delete-${Date.now()}@test.local`, password: TEST_PASSWORD, role: 'user', }); await test.step('Reload to see new user', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); }); await test.step('Find and click delete button', async () => { const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); const deleteButton = userRow.locator('button').filter({ has: page.locator('svg.lucide-trash-2'), }); await expect(deleteButton.first()).toBeVisible(); // Set up dialog handler for confirmation page.once('dialog', async (dialog) => { expect(dialog.type()).toBe('confirm'); await dialog.accept(); }); await deleteButton.first().click(); }); await test.step('Verify success toast', async () => { await waitForToast(page, /deleted|removed|success/i, { type: 'success' }); }); await test.step('Verify user no longer in list', async () => { await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ hasText: testUser.email, }); await expect(userRow).toHaveCount(0); }); }); /** * Test: Prevent self-deletion * Priority: P0 */ test('should prevent self-deletion', async ({ page, adminUser }) => { await test.step('Find current admin user in list', async () => { const userRow = page.getByRole('row').filter({ hasText: adminUser.email, }); // The delete button should be disabled for the current user // Or clicking it should show an error const deleteButton = userRow.locator('button').filter({ has: page.locator('svg.lucide-trash-2'), }); // Check if button is disabled const isDisabled = await deleteButton.first().isDisabled().catch(() => false); if (isDisabled) { expect(isDisabled).toBeTruthy(); } else { // If not disabled, clicking should show error page.once('dialog', async (dialog) => { await dialog.accept(); }); await deleteButton.first().click(); // Should show error toast about self-deletion const errorToast = page.getByText(/cannot.*delete.*yourself|self.*delete/i); await expect(errorToast).toBeVisible({ timeout: 5000 }); } }); }); /** * Test: Prevent deleting last admin * Priority: P0 */ test('should prevent deleting last admin', async ({ page, adminUser }) => { await test.step('Verify admin delete is restricted', async () => { const adminRow = page.getByRole('row').filter({ hasText: adminUser.email, }); const deleteButton = adminRow.locator('button').filter({ has: page.locator('svg.lucide-trash-2'), }); // Admin delete button should be disabled await expect(deleteButton.first()).toBeDisabled(); }); }); /** * Test: Resend invite for pending user * Priority: P2 */ test('should resend invite for pending user', async ({ page }) => { const testEmail = `resend-${Date.now()}@test.local`; await test.step('Create a pending invite', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await inviteButton.click(); const emailInput = page.getByPlaceholder(/user@example/i).first(); await emailInput.fill(testEmail); // Scope to dialog to avoid strict mode violation with "Resend Invite" button const sendButton = page.getByRole('dialog') .getByRole('button', { name: /send.*invite/i }) .first(); await sendButton.click(); // Wait for success and close modal await expect(page.getByRole('dialog').getByRole('button', { name: /done/i }).first()).toBeVisible({ timeout: 10000 }); const closeButton = page.getByRole('button', { name: /done|close|×/i }).first(); if (await closeButton.isVisible()) { await closeButton.click(); } }); await test.step('Reload and find pending user', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ hasText: testEmail, }); await expect(userRow).toBeVisible(); }); await test.step('Look for resend option', async () => { // Use row-scoped helper to find resend button in the specific user's row const resendButton = getRowScopedButton(page, testEmail, /resend invite/i); const hasResend = await resendButton.isVisible({ timeout: 3000 }).catch(() => false); if (hasResend) { await resendButton.click(); await waitForToast(page, /sent|resend/i, { type: 'success' }); } else { // Try icon-based button (mail icon) if role-based button not found const resendIconButton = getRowScopedIconButton(page, testEmail, 'lucide-mail'); const hasIconButton = await resendIconButton.isVisible({ timeout: 3000 }).catch(() => false); if (hasIconButton) { await resendIconButton.click(); await waitForToast(page, /sent|resend/i, { type: 'success' }); } else { // Resend functionality may not be implemented - return return; } } }); }); }); test.describe('Accessibility & Security', () => { /** * Test: Keyboard navigation * Priority: P1 * Uses increased loop counts and waitForTimeout for CI reliability * * SKIPPED: Known flaky test - keyboard navigation timing issues cause * tab loop to timeout before finding invite button in CI environments. * See: docs/plans/skipped-tests-remediation.md (Category 6: Flaky/Timing Issues) */ test('should be keyboard navigable', async ({ page }) => { await test.step('Tab to invite button', async () => { await page.keyboard.press('Tab'); await page.waitForTimeout(150); let foundInviteButton = false; for (let i = 0; i < 20; i++) { const focused = page.locator(':focus'); const text = await focused.textContent().catch(() => ''); if (text?.toLowerCase().includes('invite')) { foundInviteButton = true; break; } await page.keyboard.press('Tab'); await page.waitForTimeout(150); } expect(foundInviteButton).toBeTruthy(); }); await test.step('Activate with Enter key', async () => { await page.keyboard.press('Enter'); // Wait for modal animation await page.waitForTimeout(500); // Modal should open const modal = page.getByRole('dialog').or( page.locator('[class*="fixed"]').filter({ has: page.getByRole('heading', { name: /invite/i }), }) ).first(); await expect(modal).toBeVisible({ timeout: 5000 }); }); await test.step('Close modal with Escape', async () => { await page.keyboard.press('Escape'); await page.waitForTimeout(300); // Modal should close (if escape is wired up) const closeButton = page.getByRole('button', { name: /close|×|cancel/i }).first(); if (await closeButton.isVisible({ timeout: 1000 }).catch(() => false)) { await closeButton.click(); } }); await test.step('Tab through table rows', async () => { // Focus should be able to reach action buttons in table let foundActionButton = false; for (let i = 0; i < 35; i++) { await page.keyboard.press('Tab'); await page.waitForTimeout(150); const focused = page.locator(':focus'); const tagName = await focused.evaluate((el) => el.tagName.toLowerCase()).catch(() => ''); if (tagName === 'button') { const isInTable = await focused.evaluate((el) => { return !!el.closest('table'); }).catch(() => false); if (isInTable) { foundActionButton = true; break; } } } expect(foundActionButton).toBeTruthy(); }); }); /** * Test: Require admin role for access * Priority: P0 */ // Skip: Admin access control is enforced via routing/middleware, not visible error messages test('should require admin role for access', async ({ page, regularUser }) => { await test.step('Logout current admin', async () => { await logoutUser(page); }); await test.step('Login as regular user', async () => { await loginUser(page, regularUser); await waitForLoadingComplete(page); }); const listUsersResponse = await test.step('Attempt to access users page', async () => { const responsePromise = page.waitForResponse( (response) => response.request().method() === 'GET' && /\/api\/v1\/users\/?(?:\?.*)?$/.test(response.url()) && !/\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/.test(response.url()), { timeout: 15000 } ).catch(() => null); await page.goto('/users', { waitUntil: 'domcontentloaded' }); return await responsePromise; }); await test.step('Verify access denied or redirect', async () => { // Should either redirect to home/dashboard or show error const currentUrl = page.url(); const isRedirected = !currentUrl.includes('/users'); const hasForbiddenResponse = listUsersResponse?.status() === 403; const hasError = await page .getByText(/admin access required|access.*denied|not.*authorized|forbidden/i) .isVisible({ timeout: 3000 }) .catch(() => false); expect(isRedirected || hasForbiddenResponse || hasError).toBeTruthy(); }); }); /** * Test: Show error for regular user access * Priority: P0 */ // Skip: Admin access control is enforced via routing/middleware, not visible error messages test('should show error for regular user access', async ({ page, regularUser }) => { await test.step('Logout and login as regular user', async () => { await logoutUser(page); await loginUser(page, regularUser); await waitForLoadingComplete(page); }); const listUsersResponse = await test.step('Navigate to users page directly', async () => { const responsePromise = page.waitForResponse( (response) => response.request().method() === 'GET' && /\/api\/v1\/users\/?(?:\?.*)?$/.test(response.url()) && !/\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/.test(response.url()), { timeout: 15000 } ).catch(() => null); await page.goto('/users', { waitUntil: 'domcontentloaded' }); return await responsePromise; }); await test.step('Verify error message or redirect', async () => { // Check for error toast, error page, or redirect const errorMessage = page.getByText(/admin access required|access.*denied|unauthorized|forbidden|permission/i); const hasError = await errorMessage.isVisible({ timeout: 3000 }).catch(() => false); const isRedirected = !page.url().includes('/users'); const hasForbiddenResponse = listUsersResponse?.status() === 403; expect(hasError || isRedirected || hasForbiddenResponse).toBeTruthy(); }); }); /** * Test: Proper ARIA labels * Priority: P2 */ test('should have proper ARIA labels', async ({ page }) => { await test.step('Verify invite button has accessible name', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await expect(inviteButton).toBeVisible(); const accessibleName = await inviteButton.getAttribute('aria-label') || await inviteButton.textContent(); expect(accessibleName?.length).toBeGreaterThan(0); }); await test.step('Verify table has proper structure', async () => { const table = page.getByRole('table'); await expect(table).toBeVisible(); // Table should have column headers const headers = page.getByRole('columnheader'); const headerCount = await headers.count(); expect(headerCount).toBeGreaterThan(0); }); await test.step('Verify action buttons have accessible labels', async () => { const actionButtons = page.locator('td button'); const count = await actionButtons.count(); for (let i = 0; i < Math.min(count, 5); i++) { const button = actionButtons.nth(i); const isVisible = await button.isVisible().catch(() => false); if (isVisible) { const ariaLabel = await button.getAttribute('aria-label'); const title = await button.getAttribute('title'); const text = await button.textContent(); // Button should have some form of accessible name expect(ariaLabel || title || text?.trim()).toBeTruthy(); } } }); await test.step('Verify switches have accessible labels', async () => { const switches = page.getByRole('switch'); const count = await switches.count(); for (let i = 0; i < Math.min(count, 3); i++) { const switchEl = switches.nth(i); const isVisible = await switchEl.isVisible().catch(() => false); if (isVisible) { const ariaLabel = await switchEl.getAttribute('aria-label'); const ariaLabelledBy = await switchEl.getAttribute('aria-labelledby'); // Switch should have accessible name expect(ariaLabel || ariaLabelledBy).toBeTruthy(); } } }); }); }); /** * PR-3: Passthrough Role in Invite Modal (F3) * * Verifies that the invite modal exposes all three role options: * Admin, User, and Passthrough — and that selecting Passthrough * surfaces the appropriate role description. */ test.describe('PR-3: Passthrough Role in Invite (F3)', () => { test('should offer passthrough as a role option in the invite modal', async ({ page }) => { await test.step('Open the Invite User modal', async () => { const inviteButton = page.getByRole('button', { name: /invite.*user/i }); await expect(inviteButton).toBeVisible(); await inviteButton.click(); await expect(page.getByRole('dialog')).toBeVisible(); }); await test.step('Verify three role options are present in the role select', async () => { const roleSelect = page.locator('#invite-user-role'); await expect(roleSelect).toBeVisible(); await expect(roleSelect.locator('option[value="user"]')).toHaveCount(1); await expect(roleSelect.locator('option[value="admin"]')).toHaveCount(1); await expect(roleSelect.locator('option[value="passthrough"]')).toHaveCount(1); }); await test.step('Select passthrough and verify description is shown', async () => { await page.locator('#invite-user-role').selectOption('passthrough'); await expect( page.getByText(/proxy access only|no management interface/i) ).toBeVisible(); }); }); test('should show permission mode selector when passthrough role is selected', async ({ page }) => { await test.step('Open invite modal and select passthrough role', async () => { await page.getByRole('button', { name: /invite.*user/i }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.locator('#invite-user-role').selectOption('passthrough'); }); await test.step('Verify permission mode select is visible for passthrough', async () => { const permSelect = page.locator('#invite-permission-mode'); await expect(permSelect).toBeVisible(); }); }); }); /** * PR-3: User Detail Modal — Self vs Other (F2) * * Verifies that UserDetailModal shows different sections depending * on whether the admin is editing their own profile (isSelf=true) * versus another user's profile (isSelf=false). */ test.describe('PR-3: User Detail Modal (F2)', () => { test('should open My Profile modal with password and API key sections when editing self', async ({ page }) => { await test.step('Click the Edit User button in the My Profile card (first button in DOM)', async () => { // The My Profile card renders its button before the table rows in the DOM await page.getByRole('button', { name: 'Edit User' }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); }); await test.step('Verify dialog title is "My Profile" (isSelf=true)', async () => { await expect( page.getByRole('dialog').getByRole('heading', { name: 'My Profile' }) ).toBeVisible(); }); await test.step('Verify name and email input fields are present', async () => { const dialog = page.getByRole('dialog'); // First input in the dialog is the name field (no type attribute) await expect(dialog.locator('input').first()).toBeVisible(); // Email field has type="email" await expect(dialog.locator('input[type="email"]')).toBeVisible(); }); await test.step('Verify Change Password toggle is present (self-only section)', async () => { const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('button', { name: 'Change Password' })).toBeVisible(); }); await test.step('Verify API Key section is present (self-only section)', async () => { const dialog = page.getByRole('dialog'); await expect(dialog.getByText('API Key', { exact: true })).toBeVisible(); await expect(dialog.getByRole('button', { name: 'Regenerate API Key' })).toBeVisible(); }); }); test('should open Edit User modal without password/API key sections for another user', async ({ page }) => { const suffix = Date.now(); const otherUser = { email: `modal-other-${suffix}@test.local`, name: `Modal Other User ${suffix}`, password: 'TestPass123!', role: 'user', }; let otherUserId: number | string | undefined; await test.step('Create a second user so there is an "other" row in the table', async () => { const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); const resp = await page.request.post('/api/v1/users', { data: otherUser, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); expect(resp.ok()).toBe(true); const body = await resp.json(); otherUserId = body.id; await page.reload(); await waitForLoadingComplete(page); }); await test.step('Click the Edit User button in the row for the other user', async () => { const row = page.getByRole('row').filter({ hasText: otherUser.email }); await row.getByRole('button', { name: 'Edit User' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); }); await test.step('Verify dialog title is "Edit User" (isSelf=false)', async () => { await expect( page.getByRole('dialog').getByRole('heading', { name: 'Edit User' }) ).toBeVisible(); }); await test.step('Verify name and email fields are present', async () => { const dialog = page.getByRole('dialog'); await expect(dialog.locator('input').first()).toBeVisible(); await expect(dialog.locator('input[type="email"]')).toBeVisible(); }); await test.step('Verify Change Password button is NOT visible (other-user edit)', async () => { const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('button', { name: 'Change Password' })).not.toBeVisible(); }); await test.step('Verify API Key section is NOT visible (other-user edit)', async () => { const dialog = page.getByRole('dialog'); await expect(dialog.getByText('Regenerate API Key')).not.toBeVisible(); }); await test.step('Cleanup: close modal and delete the test user', async () => { await page.keyboard.press('Escape'); if (otherUserId !== undefined) { const token = await page.evaluate(() => localStorage.getItem('charon_auth_token') || ''); await page.request.delete(`/api/v1/users/${otherUserId}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } }); }); }); });