Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
498 lines
19 KiB
TypeScript
Executable File
498 lines
19 KiB
TypeScript
Executable File
/**
|
|
* Authentication E2E Tests
|
|
*
|
|
* Tests the authentication flows for the Charon application including:
|
|
* - Login with valid credentials
|
|
* - Login with invalid credentials
|
|
* - Login with non-existent user
|
|
* - Logout functionality
|
|
* - Session persistence across page refreshes
|
|
* - Session expiration handling
|
|
*
|
|
* These tests use per-test user fixtures to ensure isolation and avoid
|
|
* race conditions in parallel execution.
|
|
*
|
|
* @see /projects/Charon/docs/plans/current_spec.md - Section 4.1.2
|
|
*/
|
|
|
|
import { test, expect, loginUser, logoutUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
|
|
import { waitForToast, waitForLoadingComplete, waitForAPIResponse, waitForDebounce } from '../utils/wait-helpers';
|
|
|
|
test.describe('Authentication Flows', () => {
|
|
test.describe('Login with Valid Credentials', () => {
|
|
/**
|
|
* Test: Successful login redirects to dashboard
|
|
* Verifies that a user with valid credentials can log in and is redirected
|
|
* to the main dashboard.
|
|
*/
|
|
test('should login with valid credentials and redirect to dashboard', async ({
|
|
page,
|
|
adminUser,
|
|
}) => {
|
|
await test.step('Navigate to login page', async () => {
|
|
await page.goto('/login');
|
|
// Verify login page is loaded by checking for the email input field
|
|
await expect(page.locator('input[type="email"]')).toBeVisible();
|
|
});
|
|
|
|
await test.step('Enter valid credentials', async () => {
|
|
await page.locator('input[type="email"]').fill(adminUser.email);
|
|
await page.locator('input[type="password"]').fill(TEST_PASSWORD);
|
|
});
|
|
|
|
await test.step('Submit login form', async () => {
|
|
const responsePromise = waitForAPIResponse(page, '/api/v1/auth/login', { status: 200 });
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
await responsePromise;
|
|
});
|
|
|
|
await test.step('Verify redirect to dashboard', async () => {
|
|
await page.waitForURL('/');
|
|
await waitForLoadingComplete(page);
|
|
// Dashboard should show main content area
|
|
await expect(page.getByRole('main')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Login form shows loading state during authentication
|
|
*/
|
|
test('should show loading state during authentication', async ({ page, adminUser }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.locator('input[type="email"]').fill(adminUser.email);
|
|
await page.locator('input[type="password"]').fill(TEST_PASSWORD);
|
|
|
|
await test.step('Verify loading state on submit', async () => {
|
|
const loginButton = page.getByRole('button', { name: /sign in/i });
|
|
await loginButton.click();
|
|
|
|
// Button should be disabled or show loading indicator during request
|
|
await expect(loginButton).toBeDisabled().catch(() => {
|
|
// Some implementations use loading spinner instead
|
|
});
|
|
});
|
|
|
|
await page.waitForURL('/');
|
|
});
|
|
});
|
|
|
|
test.describe('Login with Invalid Credentials', () => {
|
|
/**
|
|
* Test: Wrong password shows error message
|
|
* Verifies that entering an incorrect password displays an appropriate error.
|
|
*/
|
|
test('should show error message for wrong password', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to login page', async () => {
|
|
await page.goto('/login');
|
|
});
|
|
|
|
await test.step('Enter valid email with wrong password', async () => {
|
|
await page.locator('input[type="email"]').fill(adminUser.email);
|
|
await page.locator('input[type="password"]').fill('WrongPassword123!');
|
|
});
|
|
|
|
await test.step('Submit and verify error', async () => {
|
|
const loginResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST',
|
|
{ timeout: 15000 },
|
|
);
|
|
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
|
|
const loginResponse = await loginResponsePromise;
|
|
expect([400, 401, 403, 404]).toContain(loginResponse.status());
|
|
|
|
const errorToast = page.getByTestId('toast-error');
|
|
const inlineError = page.getByText(/invalid|not found|failed|incorrect|unauthorized/i).first();
|
|
const hasErrorToast = await errorToast.isVisible({ timeout: 10000 }).catch(() => false);
|
|
const hasInlineError = await inlineError.isVisible({ timeout: 10000 }).catch(() => false);
|
|
|
|
expect(hasErrorToast || hasInlineError).toBe(true);
|
|
});
|
|
|
|
await test.step('Verify user stays on login page', async () => {
|
|
await expect(page).toHaveURL(/login/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Empty password shows validation error
|
|
*/
|
|
test('should show validation error for empty password', async ({ page, adminUser }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.locator('input[type="email"]').fill(adminUser.email);
|
|
// Leave password empty
|
|
|
|
await test.step('Submit and verify validation error', async () => {
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
|
|
// Check for HTML5 validation state, aria-invalid, or visible error text
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
const isInvalid =
|
|
(await passwordInput.getAttribute('aria-invalid')) === 'true' ||
|
|
(await passwordInput.evaluate((el: HTMLInputElement) => !el.validity.valid)) ||
|
|
(await page.getByText(/password.*required|required.*password/i).isVisible().catch(() => false));
|
|
|
|
expect(isInvalid).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Login with Non-existent User', () => {
|
|
/**
|
|
* Test: Unknown email shows error message
|
|
* Verifies that a non-existent user email displays an appropriate error.
|
|
*/
|
|
test('should show error message for non-existent user', async ({ page }) => {
|
|
await test.step('Navigate to login page', async () => {
|
|
await page.goto('/login');
|
|
});
|
|
|
|
await test.step('Enter non-existent email', async () => {
|
|
const nonExistentEmail = `nonexistent-${Date.now()}@test.local`;
|
|
await page.locator('input[type="email"]').fill(nonExistentEmail);
|
|
await page.locator('input[type="password"]').fill('SomePassword123!');
|
|
});
|
|
|
|
await test.step('Submit and verify error', async () => {
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
|
|
// Wait for error toast to appear (use specific test ID to avoid strict mode violation)
|
|
const errorMessage = page.getByTestId('toast-error');
|
|
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
await test.step('Verify user stays on login page', async () => {
|
|
await expect(page).toHaveURL(/login/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Invalid email format shows validation error
|
|
*/
|
|
test('should show validation error for invalid email format', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.locator('input[type="email"]').fill('not-an-email');
|
|
await page.locator('input[type="password"]').fill('SomePassword123!');
|
|
|
|
await test.step('Verify email validation error', async () => {
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
|
|
const emailInput = page.locator('input[type="email"]');
|
|
|
|
// Check for HTML5 validation state or aria-invalid or visible error text
|
|
const isInvalid =
|
|
(await emailInput.getAttribute('aria-invalid')) === 'true' ||
|
|
(await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid)) ||
|
|
(await page.getByText(/valid email|invalid email/i).isVisible().catch(() => false));
|
|
|
|
expect(isInvalid).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Logout Functionality', () => {
|
|
/**
|
|
* Test: Logout redirects to login page and clears session
|
|
* Verifies that clicking logout properly ends the session.
|
|
*
|
|
* NOTE: This test currently reveals a frontend bug where the application
|
|
* allows access to protected routes after logout. The session appears to
|
|
* persist or is not properly validated by route guards.
|
|
*/
|
|
test('should logout and redirect to login page', async ({ page, adminUser }) => {
|
|
await test.step('Login first', async () => {
|
|
await loginUser(page, adminUser);
|
|
await expect(page).toHaveURL('/');
|
|
});
|
|
|
|
await test.step('Perform logout', async () => {
|
|
await logoutUser(page);
|
|
});
|
|
|
|
await test.step('Verify redirect to login page', async () => {
|
|
await expect(page).toHaveURL(/login/);
|
|
});
|
|
|
|
await test.step('Verify session is cleared - cannot access protected route', async () => {
|
|
await page.goto('/');
|
|
await waitForLoadingComplete(page);
|
|
// Should redirect to login since session is cleared
|
|
await page.waitForURL(/login/, { timeout: 10000 }).catch(async () => {
|
|
// If no redirect, check if we're on a protected page that shows login form
|
|
const hasLoginForm = await page.locator('input[type="email"]').isVisible().catch(() => false);
|
|
if (!hasLoginForm) {
|
|
throw new Error('Expected redirect to login after session cleared. Dashboard loaded instead, indicating missing route guard.');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Logout clears authentication cookies
|
|
*/
|
|
test('should clear authentication cookies on logout', async ({ page, adminUser }) => {
|
|
await loginUser(page, adminUser);
|
|
|
|
await test.step('Verify auth cookie exists before logout', async () => {
|
|
const cookies = await page.context().cookies();
|
|
const hasAuthCookie = cookies.some(
|
|
(c) => c.name.includes('token') || c.name.includes('auth') || c.name.includes('session')
|
|
);
|
|
// Some implementations use localStorage instead of cookies
|
|
if (!hasAuthCookie) {
|
|
const localStorageToken = await page.evaluate(() =>
|
|
localStorage.getItem('token') || localStorage.getItem('authToken')
|
|
);
|
|
expect(localStorageToken || hasAuthCookie).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
await logoutUser(page);
|
|
|
|
await test.step('Verify auth data cleared after logout', async () => {
|
|
const cookies = await page.context().cookies();
|
|
const hasAuthCookie = cookies.some(
|
|
(c) =>
|
|
(c.name.includes('token') || c.name.includes('auth') || c.name.includes('session')) &&
|
|
c.value !== ''
|
|
);
|
|
|
|
const localStorageToken = await page.evaluate(() =>
|
|
localStorage.getItem('token') || localStorage.getItem('authToken')
|
|
);
|
|
|
|
expect(hasAuthCookie && localStorageToken).toBeFalsy();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Session Persistence', () => {
|
|
/**
|
|
* Test: Session persists across page refresh
|
|
* Verifies that refreshing the page maintains the logged-in state.
|
|
*/
|
|
test('should maintain session after page refresh', async ({ page, adminUser }) => {
|
|
await test.step('Login', async () => {
|
|
await loginUser(page, adminUser);
|
|
await expect(page).toHaveURL('/');
|
|
});
|
|
|
|
await test.step('Refresh page', async () => {
|
|
await page.reload();
|
|
await waitForLoadingComplete(page);
|
|
});
|
|
|
|
await test.step('Verify still logged in', async () => {
|
|
// Should still be on dashboard, not redirected to login
|
|
await expect(page).toHaveURL('/');
|
|
await expect(page.getByRole('main')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Session persists when navigating between pages
|
|
*/
|
|
test('should maintain session when navigating between pages', async ({ page, adminUser }) => {
|
|
await loginUser(page, adminUser);
|
|
|
|
await test.step('Navigate to different pages', async () => {
|
|
// Navigate to proxy hosts
|
|
const proxyHostsLink = page
|
|
.getByRole('navigation')
|
|
.getByRole('link', { name: /proxy.*hosts?/i });
|
|
if (await proxyHostsLink.isVisible()) {
|
|
await proxyHostsLink.click();
|
|
await waitForLoadingComplete(page);
|
|
await expect(page.getByRole('main')).toBeVisible();
|
|
}
|
|
|
|
// Navigate back to dashboard
|
|
await page.goto('/');
|
|
await waitForLoadingComplete(page);
|
|
});
|
|
|
|
await test.step('Verify still logged in', async () => {
|
|
await expect(page).toHaveURL('/');
|
|
await expect(page.getByRole('main')).toBeVisible();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Session Expiration Handling', () => {
|
|
/**
|
|
* Test: Expired token redirects to login
|
|
* Simulates an expired session and verifies proper redirect.
|
|
*
|
|
* NOTE: Route guard fix has been merged (2026-01-30). Validating that session
|
|
* expiration properly redirects to login page.
|
|
*/
|
|
test('should redirect to login when session expires', async ({ page, adminUser }) => {
|
|
await loginUser(page, adminUser);
|
|
|
|
await test.step('Simulate session expiration by clearing auth data', async () => {
|
|
// Clear cookies and storage to simulate expiration
|
|
await page.context().clearCookies();
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('authToken');
|
|
localStorage.removeItem('charon_auth_token');
|
|
sessionStorage.clear();
|
|
});
|
|
});
|
|
|
|
await test.step('Attempt to access protected resource', async () => {
|
|
await page.reload();
|
|
await waitForLoadingComplete(page);
|
|
});
|
|
|
|
await test.step('Verify redirect to login', async () => {
|
|
// Give the app time to detect expired session and redirect
|
|
// BUG: Currently the app shows dashboard instead of redirecting
|
|
await page.waitForURL(/login/, { timeout: 15000 }).catch(async () => {
|
|
// If no redirect, check if we're shown a login form or session expired message
|
|
const hasLoginForm = await page.locator('input[type="email"]').isVisible().catch(() => false);
|
|
const hasExpiredMessage = await page.getByText(/session.*expired|please.*login/i).isVisible().catch(() => false);
|
|
if (!hasLoginForm && !hasExpiredMessage) {
|
|
throw new Error('Expected redirect to login or session expired message. Dashboard loaded instead, indicating missing auth validation.');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: API returns 401 on expired token, UI handles gracefully
|
|
*/
|
|
test('should handle 401 response gracefully', async ({ page, adminUser }) => {
|
|
await loginUser(page, adminUser);
|
|
|
|
await test.step('Intercept API calls to return 401', async () => {
|
|
await page.route('**/api/v1/**', async (route) => {
|
|
// Let health check through, block others with 401
|
|
if (route.request().url().includes('/health')) {
|
|
await route.continue();
|
|
} else {
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ message: 'Token expired' }),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
await test.step('Trigger an API call by navigating', async () => {
|
|
await page.goto('/proxy-hosts');
|
|
// Wait for the 401 response to be processed and UI to react
|
|
await waitForDebounce(page);
|
|
});
|
|
|
|
await test.step('Verify redirect to login or error message', async () => {
|
|
// Wait for potential redirect to login page
|
|
try {
|
|
await page.waitForURL(/\/login/, { timeout: 5000 });
|
|
} catch {
|
|
// If no redirect, check for session expired message
|
|
}
|
|
|
|
// Should either redirect to login or show session expired message
|
|
const isLoginPage = page.url().includes('/login');
|
|
const hasSessionExpiredMessage = await page
|
|
.getByText(/session.*expired|please.*login|unauthorized/i)
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
expect(isLoginPage || hasSessionExpiredMessage).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Authentication Accessibility', () => {
|
|
async function pressTabUntilFocused(page: import('@playwright/test').Page, target: import('@playwright/test').Locator, maxTabs: number): Promise<void> {
|
|
for (let i = 0; i < maxTabs; i++) {
|
|
await page.keyboard.press('Tab');
|
|
const focused = await expect
|
|
.poll(async () => target.evaluate((el) => el === document.activeElement), {
|
|
timeout: 1500,
|
|
intervals: [100, 200, 300],
|
|
})
|
|
.toBeTruthy()
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (focused) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await expect(target).toBeFocused();
|
|
}
|
|
|
|
/**
|
|
* Test: Login form is keyboard accessible
|
|
*/
|
|
test('should be fully keyboard navigable', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await test.step('Tab through form elements and verify focus order', async () => {
|
|
const emailInput = page.locator('input[type="email"]');
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
const submitButton = page.getByRole('button', { name: /sign in/i });
|
|
|
|
// Focus the email input first
|
|
await emailInput.focus();
|
|
await expect(emailInput).toBeFocused();
|
|
|
|
// Tab to password field
|
|
await pressTabUntilFocused(page, passwordInput, 2);
|
|
|
|
// Tab to submit button (may go through "Forgot Password" link first)
|
|
await pressTabUntilFocused(page, submitButton, 3);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Login form has proper ARIA labels
|
|
*/
|
|
test('should have accessible form labels', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await test.step('Verify email input is visible', async () => {
|
|
const emailInput = page.locator('input[type="email"]');
|
|
await expect(emailInput).toBeVisible();
|
|
});
|
|
|
|
await test.step('Verify password input is visible', async () => {
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
await expect(passwordInput).toBeVisible();
|
|
});
|
|
|
|
await test.step('Verify submit button has accessible name', async () => {
|
|
const submitButton = page.getByRole('button', { name: /sign in/i });
|
|
await expect(submitButton).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Test: Error messages are announced to screen readers
|
|
*/
|
|
test('should announce errors to screen readers', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.locator('input[type="email"]').fill('test@example.com');
|
|
await page.locator('input[type="password"]').fill('wrongpassword');
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
|
|
await test.step('Verify error has proper ARIA role', async () => {
|
|
const errorAlert = page.locator('[role="alert"], [aria-live="polite"], [aria-live="assertive"]');
|
|
// Wait for error to appear
|
|
await expect(errorAlert).toBeVisible({ timeout: 10000 }).catch(() => {
|
|
// Some implementations may not use live regions
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|