Files
collective-lists/plan/fase-1-auth-colectivo.md
Oier Bravo Urtasun f396897cb5 test: full test suite (Vitest + pgTAP + Playwright) + TDD plan restructure
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.

Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
  each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
  promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
  caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
  as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain

Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
  auth lock (getAccessToken → getSession → initializePromise waits on the same
  lock that's held during event dispatch). Fix is two defenses: a pass-through
  lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
  push loadUserCollectives out of the callback's microtask chain. Either alone
  is insufficient; both together unblock the Playwright suite.

Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
  secret than root .env; Vite inlined that into the browser bundle so Kong (which
  uses the root .env value) rejected every request with 401. Aligned the two
  files and added a memory entry to flag this for the next rotation.

Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
  final. Completed fases (0, 1, 2a) show the pattern retroactively with the
  tests that currently cover them; pending fases (2b, 3, 4) list the tests to
  write before implementation.

Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
  #11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

103 lines
7.2 KiB
Markdown
Raw Permalink 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.
### Fase 1 — Autenticación y Colectivo ✅
**Estado: Completa** (pendiente: detección automática de idioma desde `Accept-Language`)
**Duración estimada: 23 semanas**
**Objetivo: la base del dominio. Sin esto no existe nada más.**
#### 1.0 Tests — escritos y verdes ✅
A-series (auth), B-series (colectivo), F-series (aislamiento) están cubiertas por:
- [x] Vitest integration (`packages/test-utils/tests/rls-{collective,isolation}.test.ts`) — lectura por rol, invitación, `accept_invitation()`, aislamiento no-miembro
- [x] pgTAP (`supabase/tests/001_accept_invitation.sql`, `003_promote_on_admin_leave.sql`) — happy path y casos de error de `accept_invitation`, promoción automática al último admin
- [x] Playwright E2E (`apps/web/tests/e2e/auth.test.ts`) — redirect a Keycloak, login full OAuth, ver colectivo en sidebar, sign-out, login como guest
#### 1.1 Base de datos
- [x] Migración: tabla `users` — espejo de `auth.users` de Supabase, poblada por trigger al primer login OIDC:
- `display_name`, `email`
- `language: enum(en | es)` — default `en`, sobrescrito por preferencia de navegador en primer login
- `avatar_type: enum(initials | emoji | upload)` — default `initials`
- `avatar_emoji: text | null`
- `avatar_url: text | null` — URL firmada de Supabase Storage
- [x] Migración: tabla `collectives` con campos `id`, `name`, `emoji`, `created_by`, `created_at`
- [x] Migración: tabla `collective_members` con `role: enum(admin | member | guest)`
- [x] Migración: tabla `collective_invitations` con token, expiración y estado
- [x] **RLS completo** — políticas críticas:
- Un usuario solo puede leer colectivos a los que pertenece
- Solo administradores pueden insertar en `collective_invitations`
- Solo administradores pueden actualizar roles en `collective_members`
- Un usuario solo puede actualizar su propia fila en `users`
- [x] Trigger: `on_auth_user_created` — sincroniza el perfil de Keycloak (display_name, email) a `users` en cada login
- [x] Trigger: promoción automática al miembro más antiguo si el último admin elimina su cuenta (RN-10)
- [x] Función SQL: `is_active_member(collective_id, user_id)` — usada en todas las políticas RLS
- [x] Bucket Supabase Storage: `avatars` — privado, acceso vía signed URLs, un fichero por usuario (`{user_id}/avatar`)
#### 1.2 Integración Keycloak → Supabase
> **✅ DECISIÓN: Opción B — GoTrue como proxy OIDC.**
> GoTrue recibe el token de Keycloak, lo valida y emite su propio JWT de Supabase.
> `auth.uid()` resuelve al UUID de GoTrue (que mapea al `sub` de Keycloak).
> Esto mantiene `auth.users` de Supabase poblado y permite usar todas las helpers de GoTrue.
- [x] Configurar Keycloak como OIDC Identity Provider en GoTrue (`infra/docker-compose.dev.yml``GOTRUE_EXTERNAL_KEYCLOAK_*`)
- [x] Flujo: `signInWithOAuth({ provider: 'keycloak' })` → Keycloak PKCE → GoTrue callback → Supabase session
- [x] Verificar que `auth.uid()` en las políticas RLS resuelve correctamente — ✅ curl acceptance test: los 5 usuarios autenticados vía Keycloak→GoTrue tienen `sub` correcto y las políticas RLS devuelven solo sus datos
- [x] Test de integración: token de Keycloak → exchange con GoTrue → query a Supabase con RLS → respuesta correcta — ✅ todos los 5 usuarios pasan
#### 1.3 Invitaciones (sin email)
> **✅ DECISIÓN: sin email.** Las invitaciones se crean como un link que el admin copia manualmente y comparte por cualquier canal (WhatsApp, etc.). No se integra Resend ni se envían emails.
- [x] `collective_invitations` tiene `token` único (UUID), `expires_at`, `role`, y `accepted_at`
- [x] Pantalla `/collective/manage` genera el link de invitación y muestra un botón "Copy link"
- [x] `/invitation/[token]` — el invitado abre el link, se autentica si no lo está, y acepta
- [x] Lógica de aceptación via función SQL `accept_invitation(p_token)` SECURITY DEFINER (no Edge Function)
#### 1.4 Internacionalización (i18n)
Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree-shaken por idioma, sin overhead en runtime.
- [x] Instalar y configurar `@inlang/paraglide-sveltekit` con el plugin de Vite
- [x] Crear ficheros de mensajes: `messages/en.json` y `messages/es.json`
- [ ] Configurar detección de idioma: `Accept-Language` header en primer load → guardado en `users.language` tras login
- [x] Cambio de idioma en runtime: actualizar `users.language` en Supabase + llamar a `setLanguageTag()` de Paraglide — sin recarga de página
- [x] Traducir todas las cadenas UI de la Fase 1 (onboarding, gestión de colectivo, settings)
> Las cadenas de UI van en `messages/`. Nunca hardcodear texto visible al usuario directamente en componentes.
#### 1.5 Frontend
- [x] Integración de Supabase OAuth en SvelteKit (PKCE, no keycloak-js):
- `supabase.auth.signInWithOAuth({ provider: 'keycloak' })` — compatible con Safari sin iframes
- Store Svelte `auth` — expone `currentUser`, `authLoading`, `isAuthenticated`, `displayName`
- `login()` redirige a Keycloak vía GoTrue; callback en `/auth/callback` intercambia el code
- Refresco automático de sesión por el cliente de Supabase (localStorage)
- [x] Rutas protegidas: `(app)/+layout.ts` con `ssr: false` que redirige si no hay sesión
- [x] Flujo de onboarding post-primer-login:
- Detectar si el usuario no pertenece a ningún colectivo → redirigir a `/onboarding`
- Opción A: Crear nuevo colectivo (nombre + emoji)
- Opción B: Pegar link de invitación recibido
- [x] Pantalla `/collective/manage` — miembros, roles, botón "Generate invite link" (copia al clipboard), expulsar
- [x] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado)
- [x] **Selector de colectivo activo** en la barra lateral — un usuario puede pertenecer a varios colectivos y cambiar entre ellos
- [x] Store Svelte `collective` — colectivo activo, lista de miembros, rol del usuario actual, lista de todos los colectivos del usuario
- [x] Pantalla `/settings` — configuración de usuario:
- Campo display name (input de texto, guardado automático con debounce)
- Selector de avatar con tres modos:
- *Initials* — preview generado en tiempo real desde el display name
- *Emoji* — grid de 32 emoji curados, tap para seleccionar
- *Upload* — input de fichero; recorte en el navegador 1:1 con `cropperjs`; subida a Supabase Storage bucket `avatars`
- Selector de idioma (English / Español) con efecto inmediato
- Enlace externo a Keycloak Account Console para cambio de email/contraseña
- [x] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (color determinista por hash del nombre), emoji, o `<img>` con fallback a initials
> **Auto-registro:** Self-registration está habilitado en Keycloak. Un usuario sin cuenta que recibe un link de invitación puede registrarse en la pantalla de Keycloak y volver al callback de invitación automáticamente.
#### 1.Z Verificación final ✅
- [x] `just test-all` — 0 failures (los 5 usuarios hacen login, A/B/F-series verdes)
**Criterio de aceptación:** los 5 usuarios de prueba pueden autenticarse contra Keycloak, llegar a la app con sesión activa, y `auth.uid()` en Supabase resuelve correctamente para las políticas RLS.
---