-- RLS policy tests (pgTAP), per organized-ideas.md §1: "automated tests -- specifically for the RLS policies... the actual security boundary once -- roles matter." Run with: supabase test db -- -- device_categories is used as the representative test for the shared -- catalog pattern (manufacturers/port_types/cable_types/device_templates -- all use the identical is_public/owner_id policy shape) rather than -- repeating the same assertions five times. -- -- Approach: fixture users/rows are set up as the postgres superuser (which -- bypasses RLS entirely), then we switch to the `authenticated` role and -- impersonate each fixture user in turn by setting the JWT `sub` claim that -- auth.uid() reads — the same mechanism Supabase's own runtime uses. -- -- Note on UPDATE/DELETE vs. INSERT RLS failures: an INSERT whose new row -- fails WITH CHECK always raises 42501. An UPDATE/DELETE whose target row -- doesn't satisfy USING is simply excluded from the statement — 0 rows -- affected, no error. Only an UPDATE where USING passes (the row is yours -- to touch) but the *new* values fail WITH CHECK actually throws. Tests -- below use throws_ok only for genuine WITH CHECK failures, and a plain -- update-then-assert-unchanged for the "you can't even touch this row" -- case. begin; create extension if not exists pgtap with schema extensions; select plan(103); -- ---------------------------------------------------------------------- -- Fixtures (as postgres — RLS does not apply) -- ---------------------------------------------------------------------- insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'alice@example.com', '{"username":"alice"}'), ('22222222-2222-2222-2222-222222222222', 'bob@example.com', '{"username":"bob"}'), ('33333333-3333-3333-3333-333333333333', 'carol@example.com', '{"username":"carol_admin"}'), ('44444444-4444-4444-4444-444444444444', 'dave@example.com', '{"username":"dave_superadmin"}'), -- Dedicated fixtures for the account-migration tests, kept separate from -- alice/bob/carol/dave so that block reads standalone rather than relying -- on state built up by every earlier test. ('55555555-5555-5555-5555-555555555555', 'eve@example.com', '{"username":"eve"}'), ('66666666-6666-6666-6666-666666666666', 'frank@example.com', '{"username":"frank"}'); update public.profiles set role = 'admin' where id = '33333333-3333-3333-3333-333333333333'; update public.profiles set role = 'super_admin' where id = '44444444-4444-4444-4444-444444444444'; -- Everything from here on runs as the `authenticated` role, with auth.uid() -- controlled by the JWT sub claim we set before each block. set local role authenticated; -- ---------------------------------------------------------------------- -- Catalog pattern (device_categories as the representative case) -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ insert into public.device_categories (id, name, owner_id, is_public) values ('a0000000-0000-0000-0000-000000000001', 'Alice Test Category', '11111111-1111-1111-1111-111111111111', false) $$, 'alice can insert her own private category' ); select throws_ok( $$ insert into public.device_categories (name, owner_id, is_public) values ('Sneaky Public Category', '11111111-1111-1111-1111-111111111111', true) $$, '42501'::char(5), null, 'alice cannot insert a public category directly' ); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 1, 'alice can see her own private category' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 0, 'bob cannot see alice''s private category' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ update public.device_categories set is_public = true where id = 'a0000000-0000-0000-0000-000000000001' $$, '42501'::char(5), null, 'alice cannot self-promote her category to public' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select lives_ok( $$ update public.device_categories set is_public = true where id = 'a0000000-0000-0000-0000-000000000001' $$, 'carol (admin) can promote alice''s category to public in place' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 1, 'bob can see the category now that it is public' ); -- Now-public row: alice's USING clause ("mine AND still private") no -- longer matches at all, so this update is a silent no-op, not an error. select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); update public.device_categories set name = 'Renamed' where id = 'a0000000-0000-0000-0000-000000000001'; select is( (select name from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 'Alice Test Category', 'alice (original owner) can no longer edit it now that it is public (update is a no-op)' ); -- ---------------------------------------------------------------------- -- Diagrams + collaborators -- ---------------------------------------------------------------------- select lives_ok( $$ insert into public.diagrams (id, name, owner_id, data) values ('b0000000-0000-0000-0000-000000000001', 'Alice''s Rig', '11111111-1111-1111-1111-111111111111', '{}'::jsonb) $$, 'alice can insert her own diagram' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 0, 'bob cannot see alice''s diagram before being added as a collaborator' ); select throws_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'edit') $$, '42501'::char(5), null, 'bob cannot add himself as a collaborator on alice''s diagram' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('b0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'edit') $$, '42501'::char(5), null, 'alice (owner) cannot add herself as a collaborator on her own diagram' ); select lives_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'view') $$, 'alice (owner) can add bob as a view-only collaborator' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 1, 'bob can now see alice''s diagram as a view collaborator' ); -- Bob's collaborator permission is 'view', so diagrams_update's USING -- clause doesn't match at all for him — silent no-op, not an error. update public.diagrams set name = 'Bob was here' where id = 'b0000000-0000-0000-0000-000000000001'; select is( (select name from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 'Alice''s Rig', 'bob (view-only) cannot update alice''s diagram (update is a no-op)' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ update public.diagram_collaborators set permission = 'edit' where diagram_id = 'b0000000-0000-0000-0000-000000000001' and user_id = '22222222-2222-2222-2222-222222222222' $$, 'alice (owner) can upgrade bob to edit access' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ update public.diagrams set name = 'Bob was here' where id = 'b0000000-0000-0000-0000-000000000001' $$, 'bob (edit collaborator) can now update alice''s diagram' ); select is( (select username from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '22222222-2222-2222-2222-222222222222'), 'bob', 'bob (a collaborator) can resolve the diagram''s collaborator usernames' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select throws_ok( $$ select * from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') $$, '42501'::char(5), null, 'carol (no access to the diagram at all) cannot resolve its collaborator usernames' ); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 0, 'carol (admin, not super admin) has no special visibility into alice''s diagram' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 1, 'dave (super admin) can see any diagram' ); -- ---------------------------------------------------------------------- -- Username lookup (organized-ideas.md §8's "share with @username" — any -- authenticated user can resolve one, unlike general profile browsing). -- ---------------------------------------------------------------------- select is( (select public.find_user_id_by_username('alice')), '11111111-1111-1111-1111-111111111111'::uuid, 'any authenticated user can resolve a username to an id' ); select is( (select public.find_user_id_by_username('no-such-user')), null, 'resolving an unknown username returns null, not an error' ); -- ---------------------------------------------------------------------- -- Snapshot retention (organized-ideas.md §8: keep the 50 most recent per -- diagram). Explicit, staggered created_at values below because pgTAP runs -- inside one transaction — every row would otherwise share the exact same -- now(), making "most recent" ambiguous for this test specifically (a -- real editing session naturally spreads saves out over wall-clock time). -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); insert into public.diagram_snapshots (diagram_id, data, saved_by, created_at) select 'b0000000-0000-0000-0000-000000000001', jsonb_build_object('seq', g), '11111111-1111-1111-1111-111111111111', now() + (g || ' seconds')::interval from generate_series(1, 51) g; select is( (select count(*)::int from public.diagram_snapshots where diagram_id = 'b0000000-0000-0000-0000-000000000001'), 50, 'only the 50 most recent snapshots are kept' ); select is( (select count(*)::int from public.diagram_snapshots where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '1'), 0, 'the oldest snapshot was the one pruned' ); select is( (select count(*)::int from public.diagram_snapshots where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '51'), 1, 'the newest snapshot survives' ); select is( (select username from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '11111111-1111-1111-1111-111111111111'), 'alice', 'alice (owner) can resolve who saved this diagram''s snapshots' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select throws_ok( $$ select * from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') $$, '42501'::char(5), null, 'carol (no access to the diagram at all) cannot resolve who saved its snapshots' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); -- ---------------------------------------------------------------------- -- Catalog submissions -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id) values ('device_category', '{"name":"Bob''s New Category"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$, 'bob can submit a new catalog entry for review' ); -- Fill the rest of bob's pending-submission cap (organized-ideas.md §3's -- soft cap, set to 10) and confirm the 11th is rejected. insert into public.catalog_submissions (entity_type, proposed_data, submitter_id) select 'device_category', jsonb_build_object('name', 'Bob Cap Filler ' || g), '22222222-2222-2222-2222-222222222222' from generate_series(1, 9) g; select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'), 10, 'bob has filled his pending-submission cap (10)' ); select throws_ok( $$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id) values ('device_category', '{"name":"One Too Many"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$, '42501'::char(5), null, 'bob cannot exceed the pending-submission cap' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'), 0, 'alice cannot see bob''s submission' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); -- 10, not 1: includes the 9 cap-filler submissions inserted above. select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'), 10, 'carol (admin) can see all of bob''s submissions' ); -- ---------------------------------------------------------------------- -- Withdrawing a submission — pending or rejected. A rejected submission -- previously had no way out (only 'pending' was deletable); it should be -- dismissable the same as a pending one, not stuck forever. -- ---------------------------------------------------------------------- select lives_ok( $$ update public.catalog_submissions set status = 'rejected', reviewer_id = '33333333-3333-3333-3333-333333333333', review_reason = 'Needs more detail' where id = ( select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending' order by created_at limit 1 ) $$, 'carol (admin) can reject one of bob''s pending submissions' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ delete from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected' $$, 'bob can withdraw (delete) his rejected submission' ); select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected'), 0, 'the rejected submission is gone after withdrawal' ); select lives_ok( $$ delete from public.catalog_submissions where id = ( select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending' limit 1 ) $$, 'bob can still withdraw a pending submission (unchanged behavior)' ); -- ---------------------------------------------------------------------- -- Profiles / role escalation -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ update public.profiles set role = 'admin' where id = '11111111-1111-1111-1111-111111111111' $$, '42501'::char(5), null, 'alice cannot promote her own role' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ update public.profiles set role = 'admin' where id = '11111111-1111-1111-1111-111111111111' $$, 'dave (super admin) can change another user''s role' ); -- ---------------------------------------------------------------------- -- Usage-impact function (organized-ideas.md §3's impact-check-before-editing: -- an Admin can see the blast radius of a catalog edit without being able to -- see the diagrams themselves). -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ insert into public.diagrams (id, name, owner_id, data) values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111', '{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","manufacturerId":"mf-impact-test-manufacturer","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$, 'alice can insert a diagram referencing test catalog ids for the impact-check test' ); -- bob, not alice, for this check: alice was promoted to admin by the role- -- escalation test above, so she'd no longer be a useful "regular user" case. select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$, '42501'::char(5), null, 'bob (regular user) cannot call the usage-impact function' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select is( (select diagram_count from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')), 1, 'carol (admin) sees the correct impact count for a device template, despite having no direct visibility into that diagram' ); select is( (select sample -> 0 ->> 'ownerUsername' from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')), 'alice', 'the impact sample identifies the diagram by name/owner username, not raw diagram content' ); select is( (select diagram_count from public.catalog_entity_usage_impact('port_type', 'pt-impact-test-port')), 1, 'carol (admin) sees the correct impact count for a port type' ); select is( (select diagram_count from public.catalog_entity_usage_impact('cable_type', 'ct-impact-test-cable')), 1, 'carol (admin) sees the correct impact count for a cable type' ); select is( (select diagram_count from public.catalog_entity_usage_impact('device_category', 'other')), 1, 'carol (admin) sees the correct impact count for a device category' ); select is( (select diagram_count from public.catalog_entity_usage_impact('manufacturer', 'mf-impact-test-manufacturer')), 1, 'carol (admin) sees the correct impact count for a manufacturer' ); select is( (select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')), 0, 'the impact count is zero for an entity id referenced by nothing' ); -- ---------------------------------------------------------------------- -- A regular user can check the usage-impact of their OWN still-private -- category/manufacturer (needed client-side before offering to delete it), -- but not anyone else's, and not for other entity types — see this -- migration's own comment for why that scope is safe. -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ insert into public.device_categories (id, name, owner_id, is_public) values ('c0000000-0000-0000-0000-00000000b001', 'Bob''s Test Category', '22222222-2222-2222-2222-222222222222', false) $$, 'bob can insert his own private category (for the self-check tests below)' ); select lives_ok( $$ insert into public.manufacturers (id, name, owner_id, is_public) values ('c0000000-0000-0000-0000-00000000b002', 'Bob''s Test Manufacturer', '22222222-2222-2222-2222-222222222222', false) $$, 'bob can insert his own private manufacturer (for the self-check tests below)' ); select is( (select diagram_count from public.catalog_entity_usage_impact('device_category', 'c0000000-0000-0000-0000-00000000b001')), 0, 'bob can check the usage-impact of his own private category' ); select is( (select diagram_count from public.catalog_entity_usage_impact('manufacturer', 'c0000000-0000-0000-0000-00000000b002')), 0, 'bob can check the usage-impact of his own private manufacturer' ); select throws_ok( $$ select * from public.catalog_entity_usage_impact('device_category', 'other') $$, '42501'::char(5), null, 'bob cannot check the usage-impact of a public category he doesn''t own' ); select throws_ok( $$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$, '42501'::char(5), null, 'the self-check bypass does not extend to device templates — bob still cannot check those' ); -- ---------------------------------------------------------------------- -- Batched submitter-username lookup (gated the same way as the impact -- function above — an Admin reviewing a submission can see who submitted -- it, without a general ability to browse other users' profiles). -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ select * from public.catalog_submission_submitters( array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1) ) $$, '42501'::char(5), null, 'bob (regular user) cannot look up submitter usernames' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select is( (select username from public.catalog_submission_submitters( array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1) )), 'bob', 'carol (admin) can look up the submitter''s username for a submission she can review' ); -- ---------------------------------------------------------------------- -- Admin user listing (organized-ideas.md §6: "CRUD user accounts" is -- Super-Admin-only — even a regular Admin gets 42501 here, unlike the -- Admin-gated functions above). -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ select * from public.list_users_for_admin() $$, '42501'::char(5), null, 'bob (regular user) cannot list users' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select throws_ok( $$ select * from public.list_users_for_admin() $$, '42501'::char(5), null, 'carol (admin, not super admin) cannot list users' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ select * from public.list_users_for_admin() $$, 'dave (super admin) can list users' ); select is( (select role from public.list_users_for_admin() where username = 'alice'), 'admin', 'the listing reflects alice''s current role (promoted earlier in this test run)' ); select is( (select email from public.list_users_for_admin() where username = 'alice'), 'alice@example.com', 'the listing includes email, only readable via this Super-Admin-gated function (not directly through PostgREST)' ); -- ---------------------------------------------------------------------- -- Site-wide announcements (organized-ideas.md §3): posting is gated by -- can_manage_announcements() — every Super Admin, or an Admin individually -- flagged via profiles.can_post_announcements (not the whole Admin role). -- alice is 'admin' from the role-escalation test above but was never -- flagged, so she doubles as the "admin without the flag" case; bob is -- still 'regular' throughout, untouched by that promotion. -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ insert into public.announcements (message, created_by) values ('bob trying to post', '22222222-2222-2222-2222-222222222222') $$, '42501'::char(5), null, 'bob (regular user) cannot post an announcement' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ insert into public.announcements (message, created_by) values ('alice trying to post', '11111111-1111-1111-1111-111111111111') $$, '42501'::char(5), null, 'alice (admin, no can_post_announcements flag) cannot post an announcement' ); select throws_ok( $$ update public.profiles set can_post_announcements = true where id = '11111111-1111-1111-1111-111111111111' $$, '42501'::char(5), null, 'alice cannot grant herself the can_post_announcements flag' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ update public.profiles set can_post_announcements = true where id = '33333333-3333-3333-3333-333333333333' $$, 'dave (super admin) can flag carol as allowed to post announcements' ); select is( (select can_post_announcements from public.list_users_for_admin() where username = 'carol_admin'), true, 'the user listing reflects carol''s can_post_announcements flag' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select lives_ok( $$ insert into public.announcements (id, message, created_by) values ('c0000000-0000-0000-0000-00000000a001', 'carol''s announcement', '33333333-3333-3333-3333-333333333333') $$, 'carol (admin, now flagged) can post an announcement' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ insert into public.announcements (id, message, created_by) values ('c0000000-0000-0000-0000-00000000a002', 'dave''s announcement', '44444444-4444-4444-4444-444444444444') $$, 'dave (super admin) can post an announcement, which retires carol''s' ); select isnt( (select retired_at from public.announcements where id = 'c0000000-0000-0000-0000-00000000a001'), null, 'posting a new announcement automatically retires the previous current one' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.announcements), 1, 'bob (regular user) only sees the current announcement, not retired history' ); select is( (select message from public.announcements limit 1), 'dave''s announcement', 'the one announcement bob sees is the current one' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select is( (select count(*)::int from public.announcements), 2, 'dave (super admin) can see retired announcements too, for history' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ insert into public.announcement_dismissals (user_id, announcement_id) values ('22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-00000000a002') $$, 'bob can dismiss the current announcement for himself' ); select throws_ok( $$ insert into public.announcement_dismissals (user_id, announcement_id) values ('11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-00000000a002') $$, '42501'::char(5), null, 'bob cannot record a dismissal on alice''s behalf' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select is( (select count(*)::int from public.announcement_dismissals where user_id = '22222222-2222-2222-2222-222222222222'), 0, 'alice cannot see bob''s dismissal row' ); -- ---------------------------------------------------------------------- -- Account migration (organized-ideas.md §2's Super-Admin "lost access to -- the old account" tool). Uses eve/frank — dedicated fixtures — rather than -- alice/bob/carol/dave, so this block reads standalone instead of relying -- on state accumulated by every test above it. -- -- Fixture shape, set up as eve/bob below: -- D1 (eve-owned): frank already a collaborator — exercises the -- owner-transfer dedup (frank can't end up both owner and collaborator). -- D2 (bob-owned): both eve and frank already collaborators — exercises -- the "collaborator elsewhere" dedup (eve's row is dropped, not -- duplicated, since frank already has his own). -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '55555555-5555-5555-5555-555555555555', true); select lives_ok( $$ insert into public.diagrams (id, name, owner_id, data) values ('d0000000-0000-0000-0000-00000000d001', 'Eve''s Rig', '55555555-5555-5555-5555-555555555555', '{"devices":[],"connections":[]}'::jsonb) $$, 'eve can insert her own diagram' ); select lives_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('d0000000-0000-0000-0000-00000000d001', '66666666-6666-6666-6666-666666666666', 'view') $$, 'eve can add frank as a collaborator on her diagram' ); select lives_ok( $$ insert into public.device_categories (id, name, owner_id, is_public) values ('c0000000-0000-0000-0000-00000000e001', 'Eve''s Test Category', '55555555-5555-5555-5555-555555555555', false) $$, 'eve can insert her own private category' ); select lives_ok( $$ insert into public.catalog_submissions (entity_type, entity_id, proposed_data, submitter_id) values ('device_category', 'c0000000-0000-0000-0000-00000000e001', '{"name":"Eve''s Test Category"}'::jsonb, '55555555-5555-5555-5555-555555555555') $$, 'eve can submit her category for review' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ insert into public.diagrams (id, name, owner_id, data) values ('d0000000-0000-0000-0000-00000000d002', 'Bob''s Shared Rig', '22222222-2222-2222-2222-222222222222', '{"devices":[],"connections":[]}'::jsonb) $$, 'bob can insert a diagram for the collaborator-dedup test' ); select lives_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('d0000000-0000-0000-0000-00000000d002', '55555555-5555-5555-5555-555555555555', 'edit'), ('d0000000-0000-0000-0000-00000000d002', '66666666-6666-6666-6666-666666666666', 'view') $$, 'bob can add both eve and frank as collaborators on his diagram' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$, '42501'::char(5), null, 'bob (regular user) cannot preview an account migration' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select throws_ok( $$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$, '42501'::char(5), null, 'carol (admin, not super admin) cannot preview an account migration either' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$, 'dave (super admin) can preview an account migration' ); select is( (select diagram_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')), 1, 'the preview counts eve''s one owned diagram' ); select is( (select private_entity_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')), 1, 'the preview counts eve''s one private category' ); select is( (select submission_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')), 1, 'the preview counts eve''s one submission' ); select throws_ok( $$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'notes') $$, '22023'::char(5), null, 'an account cannot be migrated into itself' ); select throws_ok( $$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', '') $$, '22023'::char(5), null, 'migrating without verification notes is rejected' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select throws_ok( $$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'notes') $$, '42501'::char(5), null, 'bob (regular user) cannot perform an account migration' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'Verified via a video call with eve.') $$, 'dave (super admin) can migrate eve''s account to frank' ); select is( (select owner_id from public.diagrams where id = 'd0000000-0000-0000-0000-00000000d001'), '66666666-6666-6666-6666-666666666666'::uuid, 'eve''s owned diagram now belongs to frank' ); select is( (select count(*)::int from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d001'), 0, 'frank''s now-redundant collaborator row on his own new diagram was dropped, not duplicated' ); select is( (select count(*)::int from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d002' and user_id = '55555555-5555-5555-5555-555555555555'), 0, 'eve''s collaborator row on bob''s diagram is gone' ); select is( (select permission from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d002' and user_id = '66666666-6666-6666-6666-666666666666'), 'view', 'frank''s own pre-existing collaborator row on bob''s diagram is untouched, not overwritten by eve''s' ); select is( (select owner_id from public.device_categories where id = 'c0000000-0000-0000-0000-00000000e001'), '66666666-6666-6666-6666-666666666666'::uuid, 'eve''s private category now belongs to frank' ); select is( (select submitter_id from public.catalog_submissions where entity_id = 'c0000000-0000-0000-0000-00000000e001'), '66666666-6666-6666-6666-666666666666'::uuid, 'eve''s submission now belongs to frank' ); select is( (select migrated_to_user_id from public.profiles where id = '55555555-5555-5555-5555-555555555555'), '66666666-6666-6666-6666-666666666666'::uuid, 'eve''s profile records where her account was migrated to' ); select is( (select count(*)::int from public.account_migrations where from_user_id = '55555555-5555-5555-5555-555555555555'), 1, 'the migration was logged exactly once' ); select is( (select diagram_count from public.account_migrations where from_user_id = '55555555-5555-5555-5555-555555555555'), 1, 'the logged row records the same diagram count the preview and migration returned' ); select throws_ok( $$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'again') $$, '22023'::char(5), null, 'an already-migrated account cannot be migrated a second time' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.account_migrations), 0, 'bob (regular user) cannot see the migration audit log' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select is( (select migrated_to_username from public.list_users_for_admin() where username = 'eve'), 'frank', 'the user listing reflects who eve''s account was migrated to' ); select * from finish(); rollback;