From 1f94dc5eb7c274e4504e45e0955a269fdd1e93bc Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:21:05 -0400 Subject: [PATCH] feat(19-05): global-setup local_credentials seed + login.spec.ts + CI harness env - global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline) - Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login) - ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job - Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export) - Full API suite: 446/446 tests pass; pnpm typecheck: exit 0 --- .gitea/workflows/ci.yml | 49 ++++++++ apps/api/tests/lib/requireAdmin.test.ts | 2 + apps/api/tests/routes/admin.test.ts | 2 + apps/api/tests/routes/authMode.test.ts | 2 + apps/api/tests/routes/lists.test.ts | 2 + apps/api/tests/routes/localAuth.test.ts | 2 + apps/api/tests/routes/push.test.ts | 4 + apps/api/tests/routes/setup.test.ts | 2 + apps/pwa/e2e/global-setup.ts | 23 ++++ apps/pwa/e2e/login.spec.ts | 145 ++++++++++++++++++++++++ 10 files changed, 233 insertions(+) create mode 100644 apps/pwa/e2e/login.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e8548d4..c99841a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -285,6 +285,49 @@ jobs: # CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use # retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides # because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run. + # Phase 19 (AUTH-LOCAL-16, D-14/D-15): seed local_credentials for dev user (id=1). + # devSessionCookieMiddleware issues a local-session cookie on each /api/* request + # when DEV_AUTH_BYPASS=true and LOCAL_SESSION_SECRET is set, so the PWA login gate + # skips /login and existing specs still reach the authed app unchanged. + # global-setup.ts also seeds this row via hashPasswordInline — this step is a + # belt-and-suspenders seed for the initial CI DB state before Playwright runs. + # The dev password 'devpass' is NOT a secret — it only exists in the ephemeral CI DB. + - name: Seed local_credentials for dev user (id=1) + env: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: familysync + DB_PASSWORD: testpass + DB_NAME: familysync + run: | + node --input-type=commonjs - <<'EOF' + const mysql = require('mysql2/promise'); + const crypto = require('crypto'); + // Inline PHC scrypt hash (matches apps/api/src/auth/localCredentials.ts) + function hashPassword(password) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$'); + } + (async () => { + const conn = await mysql.createConnection({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }); + const passwordHash = hashPassword('devpass'); + await conn.execute( + "INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)", + [passwordHash], + ); + console.log('seeded local_credentials for dev user id=1'); + await conn.end(); + })(); + EOF + working-directory: apps/pwa + - name: Run harness (start API + Playwright iphone + pixel + desktop) env: CI: 'true' @@ -298,6 +341,12 @@ jobs: NODE_OPTIONS: '--dns-result-order=ipv4first' DEV_AUTH_BYPASS: 'true' NODE_ENV: development + # Phase 19 (AUTH-LOCAL-16, D-14/D-15): LOCAL_SESSION_SECRET required for + # devSessionCookieMiddleware to issue real local-session cookies under bypass. + # This is a fixed dev-only value — NEVER a production secret. + # Must be >=32 chars (assertLocalSessionSecretSet boot guard skips in bypass mode, + # but the cookie signing requires a non-empty secret to function). + LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000' DB_HOST: mariadb DB_PORT: 3306 DB_USER: familysync diff --git a/apps/api/tests/lib/requireAdmin.test.ts b/apps/api/tests/lib/requireAdmin.test.ts index 16cea75..a27cae5 100644 --- a/apps/api/tests/lib/requireAdmin.test.ts +++ b/apps/api/tests/lib/requireAdmin.test.ts @@ -32,6 +32,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ color: '#4A90D9', }, devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), COLOR_PALETTE: ['#4A90D9'], })); diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index a71076f..c525fe4 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -93,6 +93,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed) + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ diff --git a/apps/api/tests/routes/authMode.test.ts b/apps/api/tests/routes/authMode.test.ts index 19fe7fe..57b58a5 100644 --- a/apps/api/tests/routes/authMode.test.ts +++ b/apps/api/tests/routes/authMode.test.ts @@ -49,6 +49,8 @@ vi.mock('@hono/oidc-auth', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ diff --git a/apps/api/tests/routes/lists.test.ts b/apps/api/tests/routes/lists.test.ts index bc04e5f..eff06e9 100644 --- a/apps/api/tests/routes/lists.test.ts +++ b/apps/api/tests/routes/lists.test.ts @@ -35,6 +35,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests. diff --git a/apps/api/tests/routes/localAuth.test.ts b/apps/api/tests/routes/localAuth.test.ts index 9e2a33a..ee4cbb3 100644 --- a/apps/api/tests/routes/localAuth.test.ts +++ b/apps/api/tests/routes/localAuth.test.ts @@ -77,6 +77,8 @@ vi.mock('../../src/auth/localSession.js', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ diff --git a/apps/api/tests/routes/push.test.ts b/apps/api/tests/routes/push.test.ts index e7c2349..8235a81 100644 --- a/apps/api/tests/routes/push.test.ts +++ b/apps/api/tests/routes/push.test.ts @@ -30,6 +30,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ @@ -111,6 +113,8 @@ describe('POST /api/push/subscription', () => { // and OIDC getAuth returns null — so resolveUserId returns null → 401. vi.doMock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.doMock('../../src/auth/middleware.js', () => ({ getAuth: () => null, diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts index 74900c9..2242352 100644 --- a/apps/api/tests/routes/setup.test.ts +++ b/apps/api/tests/routes/setup.test.ts @@ -101,6 +101,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ // No user injection for setup routes — pre-auth surface await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // --------------------------------------------------------------------------- diff --git a/apps/pwa/e2e/global-setup.ts b/apps/pwa/e2e/global-setup.ts index 649a97a..035c0cb 100644 --- a/apps/pwa/e2e/global-setup.ts +++ b/apps/pwa/e2e/global-setup.ts @@ -21,6 +21,16 @@ */ import mysql from 'mysql2/promise'; +import { scryptSync, randomBytes } from 'node:crypto'; + +// ── Inline scrypt PHC hashPassword ─────────────────────────────────────────── +// global-setup is plain Node.js (no @playwright/test, cannot import compiled TS). +// Copy of apps/api/src/auth/localCredentials.ts hashPassword (Pitfall 11). +function hashPasswordInline(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$'); +} export default async function globalSetup(): Promise { // ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ───── @@ -107,6 +117,7 @@ export default async function globalSetup(): Promise { await conn.execute('TRUNCATE TABLE list_shares'); await conn.execute('TRUNCATE TABLE lists'); await conn.execute('TRUNCATE TABLE calendar_events'); + await conn.execute('TRUNCATE TABLE local_credentials'); await conn.execute('SET FOREIGN_KEY_CHECKS=1'); // Phase 18: clear any stored household timezone so the timezone spec always @@ -146,6 +157,18 @@ export default async function globalSetup(): Promise { ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`, ); + // Phase 19 — Option C (AUTH-LOCAL-16): seed local_credentials for the dev user (id=1). + // devSessionCookieMiddleware issues a local-session cookie so the PWA login gate skips + // /login and the existing harness specs still reach the authed app unchanged. + // A dedicated login.spec.ts clears the cookie to test the real login form. + // hashPasswordInline is inlined (Pitfall 11 — plain Node.js, cannot import compiled TS). + await conn.execute( + `INSERT INTO local_credentials (user_id, username, password_hash) + VALUES (1, 'devuser', ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`, + [hashPasswordInline('devpass')], + ); + // CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events. // INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB). await conn.execute( diff --git a/apps/pwa/e2e/login.spec.ts b/apps/pwa/e2e/login.spec.ts new file mode 100644 index 0000000..e5c9d5d --- /dev/null +++ b/apps/pwa/e2e/login.spec.ts @@ -0,0 +1,145 @@ +/** + * 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. + * + * This spec runs in a SEPARATE browser context that clears the local-session cookie + * (via storageState:'' and explicit cookie-clear) so the real login gate fires. After + * verifying the form, it logs in as devuser/devpass to confirm the full round-trip. + * + * Specs covered: + * 1. Navigating to the app while unauthenticated → redirected to /login, brand + form visible + * 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 { + // 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('unauthenticated navigation → /login gate: brand slot and form visible', async ({ + page, + context, + baseURL, + }) => { + // Start from a clean state — no local-session cookie + await context.clearCookies(); + + // Navigate to the app root; the PWA login gate should redirect to /login + await page.goto(baseURL ?? 'http://localhost:5173', { waitUntil: 'networkidle' }); + + // 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 }); + }); +});