From 17a531550afcc47d6e186fe92fdad15cbb97389c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:43:11 -0400 Subject: [PATCH] fix(19): resolve post-merge wave-4 harness integration gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/pwa/e2e/login.spec.ts | 27 +++++++++++--------- apps/pwa/src/App.test.tsx | 52 ++++++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 6 +++++ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/pwa/e2e/login.spec.ts b/apps/pwa/e2e/login.spec.ts index e5c9d5d..f5836aa 100644 --- a/apps/pwa/e2e/login.spec.ts +++ b/apps/pwa/e2e/login.spec.ts @@ -9,12 +9,16 @@ * 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. + * 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. Navigating to the app while unauthenticated → redirected to /login, brand + form visible + * 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) * @@ -74,16 +78,15 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', () '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 ({ + test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', 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' }); + // 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/); diff --git a/apps/pwa/src/App.test.tsx b/apps/pwa/src/App.test.tsx index e375f0d..b7f0461 100644 --- a/apps/pwa/src/App.test.tsx +++ b/apps/pwa/src/App.test.tsx @@ -237,6 +237,58 @@ describe('App — setup-status gate', () => { }); }); +// Phase 19 (AUTH-LOCAL-15): the unauthenticated → /login redirect gate. This lives +// here at the unit level because the e2e harness runs DEV_AUTH_BYPASS-only (global-setup +// refuses a non-bypass DB), and under the always-on bypass /api/me is authed via DEV_USER +// injection regardless of any cookie — so a logged-out state (meQuery.isError) is +// architecturally unreachable in the browser harness. The redirect logic is controllable +// here by rejecting fetchMe. +describe('App — auth gate (Phase 19)', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.pushState({}, '', '/'); + mockFetchSetupStatus.mockResolvedValue({ setupComplete: true }); + _mockFetchAuthMode.mockResolvedValue({ localEnabled: true, oidcEnabled: false }); + }); + + it('redirects to /login when fetchMe errors (unauthenticated) and localEnabled', async () => { + mockFetchMe.mockRejectedValue(new Error('401 Unauthorized')); + + const queryClient = makeQueryClient(); + renderApp(queryClient); + + await waitFor(() => { + expect(screen.getByTestId('login-page')).toBeInTheDocument(); + }); + + // The authenticated app shell must NOT render for an unauthenticated user. + expect(screen.queryByTestId('calendar-shell')).toBeNull(); + expect(screen.queryByTestId('app-nav')).toBeNull(); + }); + + it('renders the app shell (not /login) when fetchMe succeeds', async () => { + mockFetchMe.mockResolvedValue({ + user: { + id: 1, + displayName: 'Test User', + color: '#4a90d9', + isAdmin: false, + needsProviderSetup: false, + hasLocalCredential: false, + }, + }); + + const queryClient = makeQueryClient(); + renderApp(queryClient); + + await waitFor(() => { + expect(screen.getByTestId('calendar-shell')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('login-page')).toBeNull(); + }); +}); + describe('App — setupStatus and route presence', () => { it('App.tsx references setupStatus queryKey', () => { // This test verifies the source-level contract via module inspection. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 08201be..34df070 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,12 @@ services: # NODE_ENV !== 'production' (and the production image bakes NODE_ENV=production), # so this can never activate in a shipped image. Required by the e2e harness. DEV_AUTH_BYPASS: 'true' + # Phase 19 (AUTH-LOCAL-16, D-14/D-15): required for devSessionCookieMiddleware to + # issue real local-session cookies under bypass AND for the real-login round-trip + # (POST /api/auth/local/login) to sign a session — without it that path 503s. + # Fixed dev-only value, mirrors the CI harness job (.gitea/workflows/ci.yml) — + # NEVER a production secret; this override file is dev-only (target: dev). + LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000' mariadb: ports: