Announcements: a Super Admin (or an Admin individually flagged via profiles.can_post_announcements) can post/retire a site-wide banner. Account migration: a Super Admin can move a locked-out user's diagrams, private catalog entries, and submissions to another account, with a migration-history log; ProfileModal adds the self-service half (link a new Google identity via Supabase manual linking, then unlink the old one, while signed in as the account being migrated). Also reworks the top bar's flat button row into grouped dropdown menus (Diagram / Admin / Account) now that there are enough entries to need it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
894 lines
36 KiB
PL/PgSQL
894 lines
36 KiB
PL/PgSQL
-- 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;
|