12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.
Tests:
- tests/e2e/onboarding.test.ts O-01..O-03 (Eva auto-redirect, happy
path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts I-01..I-05 (token generation, accept
logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
member, generate pending invite)
- tests/fixtures/db.ts resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership,
countMembership — all via raw SQL so they
don't depend on SUPABASE_SERVICE_ROLE_KEY
Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
`create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
used `.insert(...).select().single()` which triggers the SELECT policy on
the fresh row — creator isn't a member yet, so the INSERT was rejected with
"row-level security" even though the INSERT policy passed. onboarding now
calls the RPC which inserts both the collective AND the creator-as-admin
membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
has been silently masking this bug (only the spoof branch was asserted).
- routes/+layout.svelte: post-login redirect now reads sessionStorage
`pendingInvitationToken` and routes back to /invitation/<token> instead of
defaulting to /onboarding or /lists. The invitation page already stashed
the token before kicking off Keycloak, but nothing read it back.
- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
to currentCollective so it re-runs when the store resolves after cold
navigation. onMount alone races with the auth listener's first emit and
the member list stayed empty on direct URL hits.
- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
whether to redirect to login. Previously raced with the auth listener and
triggered a redundant Keycloak round-trip for freshly-authenticated users.
Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)
Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.
Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
56 lines
2.1 KiB
PL/PgSQL
56 lines
2.1 KiB
PL/PgSQL
-- Migration 012: atomic create_collective(name, emoji) RPC.
|
|
--
|
|
-- Background: creating a collective via two separate REST calls —
|
|
-- 1. INSERT INTO collectives ...
|
|
-- 2. INSERT INTO collective_members (role='admin') ...
|
|
-- — hits two RLS dead-ends:
|
|
--
|
|
-- a) The first INSERT, when chained with `.select().single()` (the idiomatic
|
|
-- supabase-js pattern), triggers a SELECT policy check on the returned
|
|
-- row. The SELECT policy is `is_member(id)`, and the creator isn't a
|
|
-- member yet — step 2 hasn't happened — so the whole INSERT fails.
|
|
-- b) The second INSERT is gated by `is_admin(collective_id)`, and nobody
|
|
-- is admin of the newly-created collective yet, so this also fails.
|
|
--
|
|
-- The intended semantics are "the creator becomes the first admin atomically"
|
|
-- — which is exactly what a SECURITY DEFINER function is for.
|
|
--
|
|
-- This RPC replaces the two-call pattern in the onboarding flow. It runs as
|
|
-- the function owner (supabase_admin via search_path = public default grants)
|
|
-- so it bypasses RLS for both inserts, and returns the new collective row.
|
|
|
|
CREATE OR REPLACE FUNCTION public.create_collective(
|
|
p_name text,
|
|
p_emoji text
|
|
)
|
|
RETURNS public.collectives
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_user_id uuid := auth.uid();
|
|
v_collective public.collectives%ROWTYPE;
|
|
BEGIN
|
|
IF v_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000';
|
|
END IF;
|
|
|
|
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
|
|
RAISE EXCEPTION 'name_required' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
INSERT INTO public.collectives (name, emoji, created_by)
|
|
VALUES (btrim(p_name), coalesce(p_emoji, '🏠'), v_user_id)
|
|
RETURNING * INTO v_collective;
|
|
|
|
INSERT INTO public.collective_members (collective_id, user_id, role)
|
|
VALUES (v_collective.id, v_user_id, 'admin');
|
|
|
|
RETURN v_collective;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.create_collective(text, text) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.create_collective(text, text) TO authenticated;
|