Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
3 changed files with 73 additions and 12 deletions
Showing only changes of commit 17a531550a - Show all commits
+15 -12
View File
@@ -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/);
+52
View File
@@ -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.
+6
View File
@@ -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: