Files
phaseflow/e2e/auth.spec.ts
Petru Paler 4a874476c3
All checks were successful
Deploy / deploy (push) Successful in 1m37s
Enable 5 previously skipped e2e tests
- Fix OIDC tests with route interception for auth-methods API
- Add data-testid to DecisionCard for reliable test selection
- Fix /api/today to fetch fresh user data instead of stale cookie data
- Fix period logging test timing with proper API wait patterns
- Fix decision engine test with waitForResponse instead of timeout
- Simplify mobile viewport test locator

All 206 e2e tests now pass with 0 skipped.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:30:51 +00:00

389 lines
13 KiB
TypeScript

// ABOUTME: E2E tests for authentication flows including login and logout.
// ABOUTME: Tests login page UI, form validation, rate limiting, and error handling.
import { expect, test } from "@playwright/test";
test.describe("authentication", () => {
test.describe("login page", () => {
test("login page shows loading state initially", async ({ page }) => {
await page.goto("/login");
// The page should load with some content visible
await expect(page).toHaveURL(/\/login/);
});
test("login page displays sign in option", async ({ page }) => {
await page.goto("/login");
// Wait for auth methods to load
// Either OIDC button or email/password form should be visible
await page.waitForLoadState("networkidle");
// Look for either OIDC sign-in button or email/password form
const oidcButton = page.getByRole("button", { name: /sign in with/i });
const emailInput = page.getByLabel(/email/i);
// At least one should be visible
const hasOidc = await oidcButton.isVisible().catch(() => false);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
expect(hasOidc || hasEmailForm).toBe(true);
});
test("email/password form validates empty fields", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown (vs OIDC)
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Try to submit empty form
const submitButton = page.getByRole("button", { name: /sign in/i });
await submitButton.click();
// Form should prevent submission via HTML5 validation or show error
// The form won't submit with empty required fields
await expect(emailInput).toBeFocused();
} else {
// OIDC mode - skip this test
test.skip();
}
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in invalid credentials
await emailInput.fill("invalid@example.com");
await page.getByLabel(/password/i).fill("wrongpassword");
// Submit the form
await page.getByRole("button", { name: /sign in/i }).click();
// Should show error message - use more specific selector to avoid matching Next.js route announcer
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /invalid|failed|error|wrong|something went wrong/i,
});
await expect(errorMessage).toBeVisible({ timeout: 10000 });
} else {
// OIDC mode - skip this test
test.skip();
}
});
test("clears error when user types", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in and submit invalid credentials
await emailInput.fill("invalid@example.com");
await page.getByLabel(/password/i).fill("wrongpassword");
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for error - use more specific selector
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /invalid|failed|error|wrong|something went wrong/i,
});
await expect(errorMessage).toBeVisible({ timeout: 10000 });
// Type in email field
await emailInput.fill("new@example.com");
// Error should be cleared (non-rate-limit errors)
// Note: Rate limit errors persist
await expect(errorMessage)
.not.toBeVisible({ timeout: 2000 })
.catch(() => {
// If still visible, might be rate limit - that's acceptable
});
} else {
test.skip();
}
});
test("shows disabled state during login attempt", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in credentials
await emailInput.fill("test@example.com");
await page.getByLabel(/password/i).fill("testpassword");
// Click submit and quickly check for disabled state
const submitButton = page.getByRole("button", { name: /sign in/i });
// Start the submission
const submitPromise = submitButton.click();
// The button should become disabled during submission
// Check that the button text changes to "Signing in..."
await expect(submitButton)
.toContainText(/signing in/i, { timeout: 1000 })
.catch(() => {
// May be too fast to catch - that's okay
});
await submitPromise;
} else {
test.skip();
}
});
});
test.describe("protected routes", () => {
test("dashboard redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/");
// Should either redirect to /login or show login link
const url = page.url();
const hasLoginInUrl = url.includes("/login");
const loginLink = page.getByRole("link", { name: /login|sign in/i });
if (!hasLoginInUrl) {
await expect(loginLink).toBeVisible();
} else {
await expect(page).toHaveURL(/\/login/);
}
});
test("settings redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/settings");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("calendar redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/calendar");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("history redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/history");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("plan redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/plan");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("period-history redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/period-history");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("public routes", () => {
test("login page is accessible without auth", async ({ page }) => {
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
// Should not redirect
});
test("health endpoint is accessible without auth", async ({ page }) => {
const response = await page.request.get("/api/health");
// Health endpoint returns 200 (ok) or 503 (unhealthy) - both are valid responses
expect([200, 503]).toContain(response.status());
const body = await response.json();
expect(["ok", "unhealthy"]).toContain(body.status);
});
});
test.describe("OIDC authentication flow", () => {
// Mock PocketBase auth-methods to return OIDC provider
test.beforeEach(async ({ page }) => {
await page.route("**/api/collections/users/auth-methods*", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
usernamePassword: true,
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Test Provider",
state: "mock-state",
codeVerifier: "mock-verifier",
codeChallenge: "mock-challenge",
codeChallengeMethod: "S256",
authURL: "https://mock.example.com/auth",
},
],
},
}),
});
});
});
test("OIDC button shows provider name when configured", async ({
page,
}) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
const oidcButton = page.getByRole("button", { name: /sign in with/i });
await expect(oidcButton).toBeVisible();
await expect(oidcButton).toContainText("Test Provider");
});
test("OIDC button shows loading state during authentication", async ({
page,
}) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Find button by initial text
const oidcButton = page.getByRole("button", { name: /sign in with/i });
await expect(oidcButton).toBeVisible();
// Click and immediately check for loading state
// The button text changes to "Signing in..." so we need a different locator
await oidcButton.click();
// Find the button that shows loading state (text changed)
const loadingButton = page.getByRole("button", { name: /signing in/i });
await expect(loadingButton).toBeVisible();
await expect(loadingButton).toBeDisabled();
});
test("OIDC button is disabled when rate limited", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
const oidcButton = page.getByRole("button", { name: /sign in with/i });
// Initial state should not be disabled
await expect(oidcButton).not.toBeDisabled();
});
});
test.describe("session persistence", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard
await page.waitForURL("/", { timeout: 10000 });
});
test("session persists after page refresh", async ({ page }) => {
// Verify we're on dashboard
await expect(page).toHaveURL("/");
// Refresh the page
await page.reload();
await page.waitForLoadState("networkidle");
// Should still be on dashboard, not redirected to login
await expect(page).toHaveURL("/");
// Dashboard content should be visible (not login page)
const dashboardContent = page.getByRole("heading").first();
await expect(dashboardContent).toBeVisible();
});
test("session persists when navigating between pages", async ({ page }) => {
// Navigate to settings
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Should be on settings, not redirected to login
await expect(page).toHaveURL(/\/settings/);
// Navigate to calendar
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Should be on calendar, not redirected to login
await expect(page).toHaveURL(/\/calendar/);
// Navigate back to dashboard
await page.goto("/");
await page.waitForLoadState("networkidle");
// Should still be authenticated
await expect(page).toHaveURL("/");
});
test("logout clears session and redirects to login", async ({ page }) => {
// Navigate to settings where logout button is located
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Find and click logout button
const logoutButton = page.getByRole("button", { name: /log ?out/i });
await expect(logoutButton).toBeVisible();
await logoutButton.click();
// Should redirect to login page
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
// Now try to access protected route - should redirect to login
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});
});
});