Phase 19: Local Auth (No-OIDC Mode) #23
@@ -285,6 +285,49 @@ jobs:
|
|||||||
# CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
|
# CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
|
||||||
# retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides
|
# 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.
|
# 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)
|
- name: Run harness (start API + Playwright iphone + pixel + desktop)
|
||||||
env:
|
env:
|
||||||
CI: 'true'
|
CI: 'true'
|
||||||
@@ -298,6 +341,12 @@ jobs:
|
|||||||
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
||||||
DEV_AUTH_BYPASS: 'true'
|
DEV_AUTH_BYPASS: 'true'
|
||||||
NODE_ENV: development
|
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_HOST: mariadb
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_USER: familysync
|
DB_USER: familysync
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
|
|||||||
color: '#4A90D9',
|
color: '#4A90D9',
|
||||||
},
|
},
|
||||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
COLOR_PALETTE: ['#4A90D9'],
|
COLOR_PALETTE: ['#4A90D9'],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
|
|||||||
c.set('user', { id: currentDevUserId });
|
c.set('user', { id: currentDevUserId });
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed)
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@hono/oidc-auth', () => ({
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ vi.mock('@hono/oidc-auth', () => ({
|
|||||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
devAuthBypass:
|
devAuthBypass:
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
|
|||||||
c.set('user', { id: currentDevUserId });
|
c.set('user', { id: currentDevUserId });
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests.
|
// Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests.
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ vi.mock('../../src/auth/localSession.js', () => ({
|
|||||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
devAuthBypass:
|
devAuthBypass:
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
|
|||||||
c.set('user', { id: currentDevUserId });
|
c.set('user', { id: currentDevUserId });
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@hono/oidc-auth', () => ({
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
@@ -111,6 +113,8 @@ describe('POST /api/push/subscription', () => {
|
|||||||
// and OIDC getAuth returns null — so resolveUserId returns null → 401.
|
// and OIDC getAuth returns null — so resolveUserId returns null → 401.
|
||||||
vi.doMock('../../src/auth/devBypass.js', () => ({
|
vi.doMock('../../src/auth/devBypass.js', () => ({
|
||||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
vi.doMock('../../src/auth/middleware.js', () => ({
|
vi.doMock('../../src/auth/middleware.js', () => ({
|
||||||
getAuth: () => null,
|
getAuth: () => null,
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({
|
|||||||
// No user injection for setup routes — pre-auth surface
|
// No user injection for setup routes — pre-auth surface
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import mysql from 'mysql2/promise';
|
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<void> {
|
export default async function globalSetup(): Promise<void> {
|
||||||
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
|
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
|
||||||
@@ -107,6 +117,7 @@ export default async function globalSetup(): Promise<void> {
|
|||||||
await conn.execute('TRUNCATE TABLE list_shares');
|
await conn.execute('TRUNCATE TABLE list_shares');
|
||||||
await conn.execute('TRUNCATE TABLE lists');
|
await conn.execute('TRUNCATE TABLE lists');
|
||||||
await conn.execute('TRUNCATE TABLE calendar_events');
|
await conn.execute('TRUNCATE TABLE calendar_events');
|
||||||
|
await conn.execute('TRUNCATE TABLE local_credentials');
|
||||||
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
|
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
|
||||||
// Phase 18: clear any stored household timezone so the timezone spec always
|
// Phase 18: clear any stored household timezone so the timezone spec always
|
||||||
@@ -146,6 +157,18 @@ export default async function globalSetup(): Promise<void> {
|
|||||||
ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`,
|
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.
|
// 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).
|
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|||||||
@@ -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<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('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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user