Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ The DB is **shared** (edifice + hal). Each repo holds only its own migration fil
hand-typed-timestamp bug; always use `migration new`.
- **`execute_sql` (MCP) is read/debug only — never DDL.**
- Any inline MCP `ALTER` must be mirrored as a committed migration file in the **same session**.
- **Verifying the `auth.users` provisioning trigger** (`provision_workspace_membership()`): run
the 6 numbered blocks in `scripts/verify_provisioning_trigger.sql` via `execute_sql`, one at a
time. Each is a self-contained, rolled-back, self-asserting scenario (reports `PASS`/`FAIL`,
zero residue) — pure DML, so it complies with the read/debug-only rule above. Run this after
any change to `provision_workspace_membership()`, `trg_provision_workspace_membership`,
`workspace_members`, or `halcrm_workspaces.company_id`.

---

Expand Down
287 changes: 287 additions & 0 deletions scripts/verify_provisioning_trigger.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
-- Verification checklist for provision_workspace_membership() — the SECURITY DEFINER
-- trigger on auth.users that auto-provisions workspace_members rows on invite (#63).
-- Run with: paste each numbered BEGIN … ROLLBACK block below into the Supabase MCP
-- execute_sql tool against zgkvbjqlvebttbnkklpo (or the dashboard SQL editor), one
-- block at a time. Each block is self-contained, self-asserting (RAISE NOTICE 'PASS …'
-- on success / RAISE EXCEPTION 'FAIL …' on a broken invariant) and rolls itself back —
-- zero residue on prod regardless of outcome. A RAISE EXCEPTION already aborts the
-- transaction, so the trailing ROLLBACK is what runs on the PASS path.
--
-- Covers one failure mode this trigger has already hit in prod — the 42P17 "WHEN cannot
-- reference OLD" class (guard now lives in the function body, fixed in commit f3920ab) —
-- plus the is_default partial-unique-index collision (latent per issue #61, never
-- reproduced; hardened via the #61 UNIQUE constraint) and the three non-firing /
-- idempotency paths. Does NOT cover #62 (the malformed-company_id cast guard / WHEN-clause
-- regex) — no scenario here exercises a non-UUID company_id string.
--
-- Fixtures are synthetic and greppable, never real users (Yani, Steeve, Renaud):
-- user_id 00000000-0000-4000-8000-00000000000{1..6}
-- email verify-s{1..6}@test.hal.internal
-- Real company fixtures — each must resolve to exactly one halcrm_workspaces row
-- (halcrm_workspaces.company_id is UNIQUE since 20260722202028):
-- blue-green 26a1de94-133c-4c8c-a67c-75071c0e68ab
-- ic-ingenieurs-conseils 8323074d-9f38-4d6a-8867-0bb5eb249114
--
-- instance_id uses the standard Supabase default 00000000-0000-0000-0000-000000000000.
-- If the auth.users INSERTs fail loud with an FK / NOT NULL error, check this project's
-- actual value first: SELECT DISTINCT instance_id FROM auth.users LIMIT 5;


-- ============================================================================
-- Scenario 1: Fresh INSERT with company_id present (happy path).
-- Expect: exactly one workspace_members row (blue-green, ...0001, member, is_default=true).
-- ============================================================================
BEGIN;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000001',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s1@test.hal.internal',
jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab'));

DO $$
DECLARE
v_count int;
v_default boolean;
v_role text;
BEGIN
SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000001';
IF v_count <> 1 THEN
RAISE EXCEPTION 'FAIL scenario 1: expected 1 membership, got %', v_count;
END IF;

SELECT is_default, role INTO v_default, v_role
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000001'
AND workspace_slug = 'blue-green';
IF v_default IS NOT TRUE OR v_role <> 'member' THEN
RAISE EXCEPTION 'FAIL scenario 1: expected (blue-green, member, is_default=true), got (%, is_default=%)',
v_role, v_default;
END IF;

RAISE NOTICE 'PASS scenario 1: fresh INSERT provisioned blue-green (member, is_default=true)';
END $$;

ROLLBACK;


-- ============================================================================
-- Scenario 2: company_id set only on a later UPDATE (the real auth-gateway 2-step path).
-- Step 1 INSERT with empty meta must NOT fire (WHEN gates on a non-null company_id);
-- step 2 UPDATE that first sets company_id must provision exactly one ic membership.
-- ============================================================================
BEGIN;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000002',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s2@test.hal.internal',
'{}'::jsonb);

DO $$
DECLARE
v_count int;
BEGIN
SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000002';
IF v_count <> 0 THEN
RAISE EXCEPTION 'FAIL scenario 2 (step 1): empty-meta INSERT should provision nothing, got %', v_count;
END IF;
END $$;

UPDATE auth.users
SET raw_app_meta_data = jsonb_build_object('company_id', '8323074d-9f38-4d6a-8867-0bb5eb249114')
WHERE id = '00000000-0000-4000-8000-000000000002';

DO $$
DECLARE
v_count int;
BEGIN
SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000002'
AND workspace_slug = 'ic-ingenieurs-conseils';
IF v_count <> 1 THEN
RAISE EXCEPTION 'FAIL scenario 2 (step 2): expected 1 ic-ingenieurs-conseils membership, got %', v_count;
END IF;

RAISE NOTICE 'PASS scenario 2: UPDATE-after-INSERT provisioned ic-ingenieurs-conseils membership';
END $$;

ROLLBACK;


-- ============================================================================
-- Scenario 3: is_default guard when the user already holds a default elsewhere.
-- The user must exist in auth.users before a workspace_members row can reference it
-- (FK), and provisioning must not fire before the pre-existing default is in place —
-- so the user is created with empty meta (no fire), the ic default is seeded, then
-- company_id is set via UPDATE. The new blue-green row must land is_default=false, and
-- the user must still hold exactly one is_default=true row (no unique-index violation).
-- ============================================================================
BEGIN;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000003',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s3@test.hal.internal',
'{}'::jsonb);

INSERT INTO workspace_members (workspace_slug, user_id, role, is_default)
VALUES ('ic-ingenieurs-conseils', '00000000-0000-4000-8000-000000000003', 'member', true);

UPDATE auth.users
SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab')
WHERE id = '00000000-0000-4000-8000-000000000003';

DO $$
DECLARE
v_new_default boolean;
v_default_count int;
BEGIN
SELECT is_default INTO v_new_default
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000003'
AND workspace_slug = 'blue-green';
IF v_new_default IS DISTINCT FROM false THEN
RAISE EXCEPTION 'FAIL scenario 3: new blue-green row should be is_default=false, got %', v_new_default;
END IF;

SELECT count(*) INTO v_default_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000003'
AND is_default;
IF v_default_count <> 1 THEN
RAISE EXCEPTION 'FAIL scenario 3: expected exactly one is_default=true row, got %', v_default_count;
END IF;

RAISE NOTICE 'PASS scenario 3: is_default guard held — new row is_default=false, one default total';
END $$;

ROLLBACK;


-- ============================================================================
-- Scenario 4: company_id resolves to no halcrm_workspaces row (finding M3).
-- The function must NOT abort the auth.users write and must create no membership; it
-- emits a RAISE WARNING instead. The synthetic company_id is a fresh gen_random_uuid()
-- (always canonical-dashed, so it passes the trigger's WHEN regex) that is asserted to
-- match nothing. Whole scenario lives in a DO block because the orphan id is dynamic.
-- Expect: alongside the PASS notice, a "WARNING: provision_workspace_membership: no
-- halcrm_workspaces row for company_id …" line in the execute_sql output.
-- ============================================================================
BEGIN;

DO $$
DECLARE
v_orphan_company_id uuid := gen_random_uuid();
v_count int;
BEGIN
IF EXISTS (SELECT 1 FROM halcrm_workspaces WHERE company_id = v_orphan_company_id) THEN
RAISE EXCEPTION 'FAIL scenario 4: generated orphan company_id % collided with a real workspace — rerun', v_orphan_company_id;
END IF;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000004',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s4@test.hal.internal',
jsonb_build_object('company_id', v_orphan_company_id::text));

SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000004';
IF v_count <> 0 THEN
RAISE EXCEPTION 'FAIL scenario 4: orphan company_id should provision nothing, got % membership(s)', v_count;
END IF;

RAISE NOTICE 'PASS scenario 4: orphan company_id did not abort the write and created no membership (expect a WARNING above)';
END $$;

ROLLBACK;


-- ============================================================================
-- Scenario 5: idempotent re-run — the function re-fires against an already-existing
-- (workspace_slug, user_id) membership and must silently no-op (ON CONFLICT DO NOTHING),
-- no error, no duplicate. Clearing company_id to '{}' does not fire (WHEN needs non-null),
-- so setting it back is a genuine NULL→value change that re-invokes the function.
-- ============================================================================
BEGIN;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000005',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s5@test.hal.internal',
'{}'::jsonb);

UPDATE auth.users
SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab')
WHERE id = '00000000-0000-4000-8000-000000000005';

UPDATE auth.users
SET raw_app_meta_data = '{}'::jsonb
WHERE id = '00000000-0000-4000-8000-000000000005';

UPDATE auth.users
SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab')
WHERE id = '00000000-0000-4000-8000-000000000005';

DO $$
DECLARE
v_count int;
BEGIN
SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000005';
IF v_count <> 1 THEN
RAISE EXCEPTION 'FAIL scenario 5: idempotent re-run should leave exactly 1 membership, got %', v_count;
END IF;

RAISE NOTICE 'PASS scenario 5: re-firing against an existing membership no-oped silently (1 row, no duplicate)';
END $$;

ROLLBACK;


-- ============================================================================
-- Scenario 6: a routine last_sign_in_at UPDATE must NOT re-invoke the trigger.
-- The trigger is AFTER INSERT OR UPDATE OF raw_app_meta_data, so touching any other
-- column does not even fire it. Provision one row at INSERT, then update only
-- last_sign_in_at; the membership count must stay exactly 1.
-- ============================================================================
BEGIN;

INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data)
VALUES ('00000000-0000-4000-8000-000000000006',
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
'verify-s6@test.hal.internal',
jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab'));

UPDATE auth.users
SET last_sign_in_at = now()
WHERE id = '00000000-0000-4000-8000-000000000006';

DO $$
DECLARE
v_count int;
BEGIN
SELECT count(*) INTO v_count
FROM workspace_members
WHERE user_id = '00000000-0000-4000-8000-000000000006';
IF v_count <> 1 THEN
RAISE EXCEPTION 'FAIL scenario 6: last_sign_in_at UPDATE must not re-provision, expected 1 membership, got %', v_count;
END IF;

RAISE NOTICE 'PASS scenario 6: last_sign_in_at UPDATE did not re-invoke the trigger (1 row, unchanged)';
END $$;

ROLLBACK;
Loading