Files
collective-lists/apps/web/tests/fixtures/login.ts
Oier Bravo Urtasun 5a63e5fc4a feat(dev): expose full auth stack on LAN IP for on-device preview
The dev stack previously mixed two hostnames (`localhost` in some places,
`keycloak` in others). That only worked because the laptop's /etc/hosts
maps `keycloak` to 127.0.0.1. Phones, tablets, or a second laptop had no
such mapping, so the OAuth flow broke the moment you tried to log in from
any non-laptop device.

Switched the SPA, Kong, GoTrue, and Keycloak client to all agree on a
single external host — the laptop's LAN IP (192.168.1.167) — so the full
Keycloak → GoTrue → Supabase → SvelteKit round-trip works from any device
on the same Wi-Fi.

Changes
  .env (root, ungitignored): PUBLIC_SUPABASE_URL / PUBLIC_KEYCLOAK_URL /
    PUBLIC_APP_URL now use the LAN IP (updated locally; tracked via
    apps/web/.env.development below).
  apps/web/.env.development: mirror the LAN IP so `$env/static/public`
    resolves the same on laptop and phone.
  apps/web/vite.config.ts: `server.host: true` — bind Vite to 0.0.0.0.
  apps/web/playwright.config.ts: `baseURL: PUBLIC_APP_URL ?? localhost`
    so E2E uses the same origin the stack is configured with.
  apps/web/tests/fixtures/login.ts:
    - wait for the collective button in the sidebar to render before
      returning (LAN-IP latency surfaced a pre-existing race in
      `handleCreate` where Enter fired before `$currentCollective`
      hydrated, silently no-op'ing).
    - derive expected app origin from PUBLIC_APP_URL.
  apps/web/tests/e2e/items.test.ts: scope D-04 delete assertion to the
    `[role="listitem"]` locator — with undoQueue enabled the toast text
    "Deleted <name>" also matches `getByText(itemName)` and shadowed the
    original check.
  infra/docker-compose.dev.yml:
    - GOTRUE_EXTERNAL_KEYCLOAK_URL and REDIRECT_URI now read from
      PUBLIC_KEYCLOAK_URL / PUBLIC_SUPABASE_URL so they follow the same
      host as the rest of the stack.
    - NEW: GOTRUE_URI_ALLOW_LIST with `/auth/callback` on both the LAN
      IP and localhost. Without an explicit allow-list GoTrue rejected
      `redirect_to=.../auth/callback` and fell back to SITE_URL root,
      stranding `?code=` at `/` and re-triggering signIn → infinite loop.
  keycloak/realm-export.json: `colectivo-web` client gains LAN-IP
    redirectUris and webOrigins (alongside the existing localhost
    entries). Persisted + live-applied via admin API.
  .gitignore: add .claude/ (per-project scheduler runtime state).

Verification
  just test-all → 224 passed, 4 skipped:
    34 pgTAP
    140 Vitest integration + 2 skipped (Realtime presence, upstream bug)
    11 Vitest unit
    39 Playwright + 2 skipped (mobile-swipe touch, WebKit-only)
  Phone sanity check: open http://192.168.1.167:5173 on a LAN device,
  log in as a seed user, full RLS-respecting session.

Caveats
  LAN IP (192.168.1.167) is DHCP-assigned — if it rotates, rerun the
  Keycloak admin API update (realm-export.json needs a new entry) and
  update the three PUBLIC_* URLs. Consider a Tailscale magic-DNS name
  as a stable replacement for the prod-deploy sprint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:09:32 +02:00

61 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Live login helper — runs the real Keycloak OAuth flow in the current context.
*
* We use this instead of `storageState` because Supabase JS v2's async
* `_recoverAndRefresh` doesn't reliably re-fire `onAuthStateChange`/INITIAL_SESSION
* when the browser context is built from a saved localStorage snapshot. Doing the
* full login for each test adds ~23 seconds but produces deterministic state.
*/
import type { Page } from '@playwright/test';
import type { USERS } from './users.js';
export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USERS]): Promise<void> {
await page.goto('/');
await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
timeout: 15_000
});
await page.fill('#username', user.email);
await page.fill('#password', user.password);
await page.click('#kc-login');
// Wait until we're redirected OFF the callback page. Origin depends on the
// PUBLIC_APP_URL the stack is configured with (localhost for plain dev; LAN
// IP like http://192.168.1.167:5173 when phones are in the loop).
const appOrigin = new URL(process.env.PUBLIC_APP_URL ?? 'http://localhost:5173').origin;
await page.waitForURL(
(url) => url.origin === appOrigin && !url.pathname.startsWith('/auth/callback'),
{ timeout: 20_000 }
);
await page.waitForFunction(
() =>
Object.keys(window.localStorage).some(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
),
null,
{ timeout: 15_000 }
);
// Wait for `$currentCollective` to populate — any test that creates rows
// needs this, because `handleCreate` silently returns when collective is
// null. On localhost the round-trip was fast enough that races were rare,
// but over LAN IP the extra latency exposes the race. We detect it by the
// sidebar's collective button rendering the actual name (otherwise it
// shows the app_name fallback "Colectivo").
await page.waitForFunction(
(appName) => {
const buttons = document.querySelectorAll('button');
for (const b of Array.from(buttons)) {
const txt = b.textContent?.trim();
if (txt && txt !== appName && !txt.startsWith(appName)) {
// Heuristic: any non-default button with emoji-prefixed name
// (seed collective is "Casa García-López" with emoji "🏠").
if (/\p{Emoji}/u.test(txt)) return true;
}
}
return false;
},
'Colectivo',
{ timeout: 10_000 }
);
}