@consiliency/agent-board-schema 1.7.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ -- ROOMUIAPI pgTAP: owner/admin-gated list_board_pending_memberships reader +
2
+ -- SECURITY INVOKER resolve_agent_handle resolver.
3
+ --
4
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
5
+ -- ('authenticated' => false) and request.jwt.claim.sub picks the acting agent.
6
+ -- Fixture style mirrors agent_board_roomdeleg.test.sql (ensure_github_user,
7
+ -- set_config role/sub, suspended-agent-as-null-actor trick).
8
+ --
9
+ -- Pending fixture rows (requested/invited/admin/board-B/dedup/demote-leave) are
10
+ -- seeded by DIRECT INSERT into board_memberships (runs as postgres, bypassing RLS +
11
+ -- the owner-gate coupling of request_join_board/invite_board_member). The owner
12
+ -- membership comes from create_board's ROOMOWN seed.
13
+ --
14
+ -- HAND-COUNTED plan: R1,R2,R3,R4,R5,R6,R7,R8,U-4,H1,H2,H3 = 12 assertions, one each.
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public, agent_board;
17
+
18
+ begin;
19
+
20
+ select plan(12);
21
+
22
+ create temp table _roomuiapi_fixture (
23
+ board_a uuid,
24
+ board_b uuid,
25
+ alice uuid, -- creator, seeded owner of board A (and B)
26
+ admin uuid, -- role 'admin' on board A
27
+ plainmember uuid, -- role 'member', joined on board A (PRIVACY CRUX actor)
28
+ nomember uuid, -- active agent, no membership on board A
29
+ suspended uuid, -- suspended agent WITH a valid auth user (null actor)
30
+ demoteleave uuid, -- current row role=member/state=left (U-4 gate reject)
31
+ requester uuid, -- pending 'requested' on board A
32
+ invitee uuid, -- pending 'invited' on board A
33
+ dedup uuid, -- older 'requested' + newer 'joined' (R8: not returned)
34
+ boardb_requester uuid, -- pending 'requested' on board B (R7 isolation)
35
+ requester_handle text,
36
+ suspended_handle text,
37
+ alice_user uuid,
38
+ admin_user uuid,
39
+ plainmember_user uuid,
40
+ nomember_user uuid,
41
+ suspended_user uuid,
42
+ demoteleave_user uuid
43
+ ) on commit drop;
44
+
45
+ do $$
46
+ declare
47
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
48
+ v_alice_user uuid;
49
+ v_admin_user uuid;
50
+ v_plainmember_user uuid;
51
+ v_nomember_user uuid;
52
+ v_suspended_user uuid;
53
+ v_demoteleave_user uuid;
54
+ v_alice uuid := gen_random_uuid();
55
+ v_admin uuid := gen_random_uuid();
56
+ v_plainmember uuid := gen_random_uuid();
57
+ v_nomember uuid := gen_random_uuid();
58
+ v_suspended uuid := gen_random_uuid();
59
+ v_demoteleave uuid := gen_random_uuid();
60
+ v_requester uuid := gen_random_uuid();
61
+ v_invitee uuid := gen_random_uuid();
62
+ v_dedup uuid := gen_random_uuid();
63
+ v_boardb_requester uuid := gen_random_uuid();
64
+ v_board_a uuid;
65
+ v_board_b uuid;
66
+ begin
67
+ v_alice_user := public.ensure_github_user('ru-alice-' || v_suffix, 'ru-alice-' || v_suffix || '@example.test', 'ru-alice-' || v_suffix, 'RU Alice');
68
+ v_admin_user := public.ensure_github_user('ru-admin-' || v_suffix, 'ru-admin-' || v_suffix || '@example.test', 'ru-admin-' || v_suffix, 'RU Admin');
69
+ v_plainmember_user := public.ensure_github_user('ru-member-' || v_suffix, 'ru-member-' || v_suffix || '@example.test', 'ru-member-' || v_suffix, 'RU Member');
70
+ v_nomember_user := public.ensure_github_user('ru-nomember-' || v_suffix, 'ru-nomember-' || v_suffix || '@example.test', 'ru-nomember-' || v_suffix, 'RU NoMember');
71
+ v_suspended_user := public.ensure_github_user('ru-susp-' || v_suffix, 'ru-susp-' || v_suffix || '@example.test', 'ru-susp-' || v_suffix, 'RU Suspended');
72
+ v_demoteleave_user := public.ensure_github_user('ru-demo-' || v_suffix, 'ru-demo-' || v_suffix || '@example.test', 'ru-demo-' || v_suffix, 'RU DemoteLeave');
73
+
74
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
75
+ values
76
+ (v_alice, 'agent', 'ru-alice-' || v_suffix, 'RU Alice', 'active', v_alice_user, v_alice_user),
77
+ (v_admin, 'agent', 'ru-admin-' || v_suffix, 'RU Admin', 'active', v_admin_user, v_admin_user),
78
+ (v_plainmember, 'agent', 'ru-member-' || v_suffix, 'RU Member', 'active', v_plainmember_user, v_plainmember_user),
79
+ (v_nomember, 'agent', 'ru-nomember-' || v_suffix, 'RU NoMember', 'active', v_nomember_user, v_nomember_user),
80
+ -- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
81
+ -- so it resolves to null while auth.uid() still maps (fail-closed test).
82
+ (v_suspended, 'agent', 'ru-susp-' || v_suffix, 'RU Suspended', 'suspended', v_suspended_user, v_suspended_user),
83
+ (v_demoteleave, 'agent', 'ru-demo-' || v_suffix, 'RU DemoteLeave', 'active', v_demoteleave_user, v_demoteleave_user),
84
+ (v_requester, 'agent', 'ru-requester-' || v_suffix, 'RU Requester', 'active', null, null),
85
+ (v_invitee, 'agent', 'ru-invitee-' || v_suffix, 'RU Invitee', 'active', null, null),
86
+ (v_dedup, 'agent', 'ru-dedup-' || v_suffix, 'RU Dedup', 'active', null, null),
87
+ (v_boardb_requester, 'agent', 'ru-brequester-' || v_suffix, 'RU BRequester', 'active', null, null);
88
+
89
+ -- Alice creates both boards (service_role null-actor here -> supplied arg honored);
90
+ -- create_board seeds Alice's joined owner membership (ROOMOWN) on each.
91
+ select board_id into v_board_a
92
+ from agent_board.create_board(v_alice, 'roomuiapi-a-' || v_suffix, 'ROOMUIAPI Board A', 'reader test', '{}'::jsonb, 'private');
93
+ select board_id into v_board_b
94
+ from agent_board.create_board(v_alice, 'roomuiapi-b-' || v_suffix, 'ROOMUIAPI Board B', 'isolation', '{}'::jsonb, 'private');
95
+
96
+ -- Direct-insert the remaining board A memberships (bypasses RLS + owner-gate).
97
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
98
+ values
99
+ (v_board_a, v_admin, 'joined', v_alice, 'admin'),
100
+ (v_board_a, v_plainmember, 'joined', v_alice, 'member'),
101
+ (v_board_a, v_requester, 'requested', v_requester, 'member'),
102
+ (v_board_a, v_invitee, 'invited', v_alice, 'member');
103
+
104
+ -- U-4: demote-and-leave. Under in-place upsert an agent keeps ONE row; here the
105
+ -- current row is role=member/state=left, so the plain-exists owner/admin gate must
106
+ -- reject it (no stale owner/joined row lingers to falsely satisfy exists()).
107
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
108
+ values
109
+ (v_board_a, v_demoteleave, 'left', v_alice, 'member', now());
110
+
111
+ -- R8 dedup: EXPLICIT distinct created_at (the whole suite runs in one begin; so
112
+ -- default now() is constant). Older 'requested' + newer 'joined' -> latest row is
113
+ -- 'joined' -> the reader's distinct-on-latest drops this agent from the pending set.
114
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, created_at)
115
+ values
116
+ (v_board_a, v_dedup, 'requested', v_dedup, 'member', now() - interval '1 hour'),
117
+ (v_board_a, v_dedup, 'joined', v_alice, 'member', now());
118
+
119
+ -- Board B: its own pending requester (R7 cross-board isolation).
120
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
121
+ values
122
+ (v_board_b, v_boardb_requester, 'requested', v_boardb_requester, 'member');
123
+
124
+ insert into _roomuiapi_fixture values
125
+ (v_board_a, v_board_b, v_alice, v_admin, v_plainmember, v_nomember, v_suspended,
126
+ v_demoteleave, v_requester, v_invitee, v_dedup, v_boardb_requester,
127
+ 'ru-requester-' || v_suffix, 'ru-susp-' || v_suffix,
128
+ v_alice_user, v_admin_user, v_plainmember_user, v_nomember_user, v_suspended_user, v_demoteleave_user);
129
+ end
130
+ $$;
131
+
132
+ select set_config('request.jwt.claim.role', 'authenticated', true);
133
+
134
+ -- === list_board_pending_memberships ===============================================
135
+ -- R1: owner (Alice) reads board A -> exactly the 2 pending rows (requested+invited).
136
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
137
+ select is(
138
+ (select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
139
+ 2, 'R1: the owner sees exactly the two pending memberships (requested + invited)');
140
+
141
+ -- R2: admin reads board A -> same 2 pending rows.
142
+ select set_config('request.jwt.claim.sub', (select admin_user from _roomuiapi_fixture)::text, true);
143
+ select is(
144
+ (select count(*)::int from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture))),
145
+ 2, 'R2: an admin sees the two pending memberships');
146
+
147
+ -- R3: the owner sees the requester's handle (content, not just count).
148
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
149
+ select is(
150
+ (select p.handle
151
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
152
+ where p.agent_id = (select requester from _roomuiapi_fixture)),
153
+ (select requester_handle from _roomuiapi_fixture),
154
+ 'R3: the owner sees the requester''s handle in the pending row');
155
+
156
+ -- R4: a plain joined member is rejected -> 42501 (PRIVACY CRUX).
157
+ select set_config('request.jwt.claim.sub', (select plainmember_user from _roomuiapi_fixture)::text, true);
158
+ select throws_ok(
159
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
160
+ '42501', 'only a board owner or admin may list pending memberships',
161
+ 'R4: a plain joined member cannot enumerate pending memberships');
162
+
163
+ -- R5: a non-member (unmapped sub) is rejected -> 42501.
164
+ select set_config('request.jwt.claim.sub', gen_random_uuid()::text, true);
165
+ select throws_ok(
166
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
167
+ '42501', 'only a board owner or admin may list pending memberships',
168
+ 'R5: a non-member is rejected');
169
+
170
+ -- R6: a suspended principal (current_agent_id() null) is rejected -> 42501 (fail-closed).
171
+ select set_config('request.jwt.claim.sub', (select suspended_user from _roomuiapi_fixture)::text, true);
172
+ select throws_ok(
173
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
174
+ '42501', 'only a board owner or admin may list pending memberships',
175
+ 'R6: a suspended principal (null actor) is rejected (fail-closed)');
176
+
177
+ -- R7: owner of A reading board A does NOT see board B's requester (cross-board isolation).
178
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
179
+ select is(
180
+ (select count(*)::int
181
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
182
+ where p.agent_id = (select boardb_requester from _roomuiapi_fixture)),
183
+ 0, 'R7: board B''s requester is absent from board A''s pending list (cross-board isolation)');
184
+
185
+ -- R8: the dedup agent (latest row 'joined', older 'requested') is NOT returned.
186
+ select is(
187
+ (select count(*)::int
188
+ from agent_board.list_board_pending_memberships((select board_a from _roomuiapi_fixture)) p
189
+ where p.agent_id = (select dedup from _roomuiapi_fixture)),
190
+ 0, 'R8: an agent whose latest row is joined (older requested) is not returned (dedup)');
191
+
192
+ -- U-4: demote-and-leave actor (current row role=member/state=left) is rejected -> 42501.
193
+ select set_config('request.jwt.claim.sub', (select demoteleave_user from _roomuiapi_fixture)::text, true);
194
+ select throws_ok(
195
+ (select format($q$select agent_board.list_board_pending_memberships(%L::uuid)$q$, board_a) from _roomuiapi_fixture),
196
+ '42501', 'only a board owner or admin may list pending memberships',
197
+ 'U-4: a demoted-and-left actor (latest row member/left) is rejected by the gate');
198
+
199
+ -- === resolve_agent_handle (SECURITY INVOKER) ======================================
200
+ -- H1: exact active handle -> the agent_id.
201
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomuiapi_fixture)::text, true);
202
+ select is(
203
+ (select r.agent_id
204
+ from agent_board.resolve_agent_handle((select requester_handle from _roomuiapi_fixture)) r),
205
+ (select requester from _roomuiapi_fixture),
206
+ 'H1: the resolver maps an exact active handle to the agent_id');
207
+
208
+ -- H2: unknown handle -> 0 rows.
209
+ select is(
210
+ (select count(*)::int
211
+ from agent_board.resolve_agent_handle('ru-does-not-exist-' || gen_random_uuid()::text)),
212
+ 0, 'H2: an unknown handle resolves to zero rows');
213
+
214
+ -- H3: a suspended agent's handle -> 0 rows (status='active' filter).
215
+ select is(
216
+ (select count(*)::int
217
+ from agent_board.resolve_agent_handle((select suspended_handle from _roomuiapi_fixture))),
218
+ 0, 'H3: a suspended agent''s handle resolves to zero rows (active filter)');
219
+
220
+ select set_config('request.jwt.claim.role', '', true);
221
+ select set_config('request.jwt.claim.sub', '', true);
222
+
223
+ select finish();
224
+
225
+ rollback;