Files
familysync/apps/pwa/e2e/login.spec.ts
T
Lucas BergerandClaude Opus 4.8 17a531550a fix(19): resolve post-merge wave-4 harness integration gaps
Two issues surfaced only when plans 19-04 (login UI) and 19-05 (Option C
bypass + login.spec) were merged together and run against the real stack —
neither executor could catch them in isolation:

1. LOCAL_SESSION_SECRET was added to the CI harness (ci.yml) but not to the
   local dev stack (docker-compose.dev.yml). Without it the real-login success
   path (POST /api/auth/local/login) 503s when signing the session cookie, so
   the e2e round-trip failed. Add the same fixed dev-only value to the dev
   compose override (dev-only target; never a production secret).

2. login.spec test 1 assumed clearing the local-session cookie yields a
   logged-out state, but under the always-on DEV_AUTH_BYPASS devAuthBypass()
   injects DEV_USER into /api/me regardless of any cookie — a logged-out state
   is architecturally unreachable in this bypass-only harness. Reframe the test
   to drive /login directly (validating the real-browser render of all brand +
   form surfaces) and move the unauthenticated root->/login redirect-gate
   coverage to a unit test in App.test.tsx where meQuery.isError is controllable.

Result: API 446/446, PWA 265/265 (+2 gate tests), e2e desktop 42 passed / 3
skipped (all login specs green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:43:11 -04:00

149 lines
6.6 KiB
TypeScript

/**
* login.spec.ts — Phase 19 AUTH-LOCAL-12/15/16
*
* Real-login-form e2e tests covering the PWA login gate + form interaction.
*
* Strategy (Option C):
* The global-setup seeds 'devuser'/'devpass' into local_credentials and the API's
* devSessionCookieMiddleware issues a local-session cookie on every /api/* request
* under DEV_AUTH_BYPASS=true. The OTHER specs (layout, calendar, lists) rely on that
* cookie being present and do NOT clear it — they still reach the authed app unchanged.
*
* IMPORTANT — bypass constraint: this harness is DEV_AUTH_BYPASS-only (global-setup
* refuses a non-bypass DB). Under the bypass, devAuthBypass() injects DEV_USER into
* every /api/* request, so /api/me is authed regardless of the local-session cookie —
* clearing the cookie does NOT produce a logged-out state in the browser. We therefore
* exercise the /login page DIRECTLY (the /login route always renders the form) for the
* form + real-login round-trip, and cover the unauthenticated root→/login redirect gate
* at the unit level in src/App.test.tsx (where meQuery.isError is controllable).
*
* Specs covered:
* 1. /login renders all brand + form surfaces (real browser, real CSS/tokens)
* 2. Wrong password → single "Incorrect username or password." error message
* 3. Correct devuser/devpass → navigates into the app (out of /login)
*
* Only runs on the desktop/chromium project (Chromium handles local-session cookies
* consistently; WebKit PWA restrictions are irrelevant here since the login form is
* a normal web page, not a Home Screen PWA). Other profiles inherit the bypass cookie.
*
* Run:
* pnpm --filter @familysync/pwa test:e2e --grep "login"
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
*/
import { test, expect, type BrowserContext } from '@playwright/test';
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
const SELECTORS = {
usernameInput: '#login-username',
// password input has id="login-password" (UI-SPEC Surface 5)
passwordInput: '#login-password',
// Primary submit: role=button with name "Sign in" (UI-SPEC Surface 7)
submitBtn: 'button[type="submit"]',
// Error message is in a role="status" element (UI-SPEC Surface 6)
errorMessage: '[role="status"]',
};
/**
* Build an unauthenticated browser context by clearing all cookies and storage.
* The devSessionCookieMiddleware issues a new local-session cookie on each API
* request, so we need to clear the cookie from the BROWSER side. Navigating to
* a page that clears the cookie header is the reliable approach in Playwright.
*/
async function makeUnauthContext(
context: BrowserContext,
baseURL: string,
): Promise<void> {
// Clear all cookies (removes the local-session cookie set by prior API calls)
await context.clearCookies();
// Also clear localStorage/sessionStorage to avoid any cached auth state
const page = await context.newPage();
try {
// Navigate somewhere to gain origin access, then clear storage
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
await page.evaluate(() => {
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
});
} finally {
await page.close();
}
}
// Only run these specs on the desktop profile. The login form is a standard web
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
test.describe('Login form — real auth round-trip (desktop/Chromium only)', () => {
test.skip(
({ browserName }) => browserName !== 'chromium',
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
);
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({
page,
}) => {
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
// injection regardless of the cookie, so visiting / lands on /calendar and a
// logged-out state is unreachable here. The redirect gate is unit-tested in
// src/App.test.tsx; this e2e proves /login renders every surface in a real browser.
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Assert we are on the /login route
await expect(page).toHaveURL(/\/login/);
// Brand slot: "FamilySync" text should be visible (UI-SPEC Surface 2)
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible();
// Login card heading "Sign in" (UI-SPEC Surface 3)
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
// Username field (UI-SPEC Surface 4)
await expect(page.locator(SELECTORS.usernameInput)).toBeVisible();
// Password field (UI-SPEC Surface 5)
await expect(page.locator(SELECTORS.passwordInput)).toBeVisible();
// Submit button (UI-SPEC Surface 7)
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('wrong password shows single "Incorrect username or password." error', async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in wrong credentials
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Error message appears (UI-SPEC Surface 6 — "Incorrect username or password.")
const errorEl = page.locator(SELECTORS.errorMessage);
await expect(errorEl).toBeVisible({ timeout: 5_000 });
await expect(errorEl).toContainText('Incorrect username or password.');
// Still on /login
await expect(page).toHaveURL(/\/login/);
});
test('correct devuser/devpass logs in and navigates out of /login', async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' });
// Fill in the seeded dev credentials (global-setup seeds devuser/devpass)
await page.locator(SELECTORS.usernameInput).fill('devuser');
await page.locator(SELECTORS.passwordInput).fill('devpass');
await page.getByRole('button', { name: 'Sign in' }).click();
// After login, the page navigates away from /login (to / or /calendar)
await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 });
});
});