1456 lines
54 KiB
TypeScript
1456 lines
54 KiB
TypeScript
/**
|
||
* 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}` } : {},
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|