Files
Charon/tests/settings/user-management.spec.ts

1296 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/);
// Wait for page to fully load - heading may take time to render
const heading = page.getByRole('heading', { level: 1 });
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();
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)
const clipboardText = await page.evaluate(async () => {
try {
return await navigator.clipboard.readText();
} catch (err) {
throw new Error(`clipboard.readText() failed: ${err?.message || err}`);
}
});
expect(clipboardText).toContain('accept-invite');
expect(clipboardText).toContain('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();
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();
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();
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();
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();
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();
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();
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
const isDisabled = await deleteButton.first().isDisabled().catch(() => true);
expect(isDisabled).toBeTruthy();
});
});
/**
* 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();
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();
}
}
});
});
});
});