@consiliency/agent-board-schema 1.5.0 → 1.9.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.
- package/fixtures/already-applied.json +20 -0
- package/migrations/20260724000100_agent_board_roomlink_invite_tokens.sql +490 -0
- package/migrations/20260724000200_agent_board_roomlink_enroll_binding.sql +285 -0
- package/migrations/20260725000100_agent_board_roomdeleg_role_gate.sql +232 -0
- package/migrations/20260726000100_agent_board_localauth_self_enroll.sql +172 -0
- package/migrations/20260727000100_agent_board_roomuiapi.sql +122 -0
- package/package.json +1 -1
- package/schema-manifest.json +48 -3
- package/src/tooling.test.mjs +15 -2
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_identity_providers.test.sql +3 -3
- package/tests/agent_board_localauth.test.sql +171 -0
- package/tests/agent_board_roomdeleg.test.sql +167 -0
- package/tests/agent_board_roomlink.test.sql +364 -0
- package/tests/agent_board_roomuiapi.test.sql +225 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
-- ROOMLINK pgTAP: shareable board invite links (create/redeem/revoke) + the
|
|
2
|
+
-- enroll-on-redeem binding. Exercises the panel's correctness invariants:
|
|
3
|
+
-- * grantor-bounded mint; owner-preset forbidden; admin-preset requires a use cap
|
|
4
|
+
-- and a <=7d TTL (R-LINK-3),
|
|
5
|
+
-- * create actor-guard (_resolved_actor_agent_id),
|
|
6
|
+
-- * atomic rank-max redemption that never downgrades a higher role (R-LINK-1),
|
|
7
|
+
-- * idempotent replay of a maxed link (M1) + exhaustion + expiry + revoked
|
|
8
|
+
-- (uniform 42501),
|
|
9
|
+
-- * suspended-creator refusal (R-LINK-2) + creator-ceiling re-bind,
|
|
10
|
+
-- * kicked-agent ('removed') refusal (R-LINK-7),
|
|
11
|
+
-- * redeem actor fail-closed on null (suspended principal),
|
|
12
|
+
-- * _redeem_invite_for_agent is NOT authenticated-callable,
|
|
13
|
+
-- * enroll_federated_identity(...,p_invite_code) soft-honors a code
|
|
14
|
+
-- (invite_redeemed=true) and soft-fails a bad one (enroll still succeeds).
|
|
15
|
+
--
|
|
16
|
+
-- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
|
|
17
|
+
-- (''/service_role => true, 'authenticated' => false) and request.jwt.claim.sub picks
|
|
18
|
+
-- the acting agent. Fixture style mirrors agent_board_roomown.test.sql. The two
|
|
19
|
+
-- Ed25519 public keys + fingerprints are reused from agent_board_enroll_federated_identity.
|
|
20
|
+
create extension if not exists pgtap with schema extensions;
|
|
21
|
+
set search_path = extensions, public, agent_board;
|
|
22
|
+
|
|
23
|
+
begin;
|
|
24
|
+
|
|
25
|
+
select plan(34);
|
|
26
|
+
|
|
27
|
+
create temp table _lf (
|
|
28
|
+
board uuid,
|
|
29
|
+
alice uuid, bob uuid, carol uuid, carol2 uuid, carol3 uuid,
|
|
30
|
+
dave uuid, erin uuid, frank uuid, gus uuid, grimm uuid, harry uuid, mallory uuid,
|
|
31
|
+
alice_user uuid, bob_user uuid, carol_user uuid, mallory_user uuid,
|
|
32
|
+
code_member text, code_maxed text, code_public text, code_mallory text,
|
|
33
|
+
code_harry text, code_revoke text, code_revoke_authz text, code_enroll text,
|
|
34
|
+
code_expired text,
|
|
35
|
+
prov_iss text, prov_sub text, prov_slug text,
|
|
36
|
+
pub1 text, fp1 text, pub2 text, fp2 text
|
|
37
|
+
) on commit drop;
|
|
38
|
+
|
|
39
|
+
create temp table _er (label text, agent_id uuid, invite_redeemed boolean) on commit drop;
|
|
40
|
+
|
|
41
|
+
do $$
|
|
42
|
+
declare
|
|
43
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
44
|
+
v_alice_user uuid; v_bob_user uuid; v_carol_user uuid; v_mallory_user uuid;
|
|
45
|
+
v_alice uuid := gen_random_uuid();
|
|
46
|
+
v_bob uuid := gen_random_uuid();
|
|
47
|
+
v_carol uuid := gen_random_uuid();
|
|
48
|
+
v_carol2 uuid := gen_random_uuid();
|
|
49
|
+
v_carol3 uuid := gen_random_uuid();
|
|
50
|
+
v_dave uuid := gen_random_uuid();
|
|
51
|
+
v_erin uuid := gen_random_uuid();
|
|
52
|
+
v_frank uuid := gen_random_uuid();
|
|
53
|
+
v_gus uuid := gen_random_uuid();
|
|
54
|
+
v_grimm uuid := gen_random_uuid();
|
|
55
|
+
v_harry uuid := gen_random_uuid();
|
|
56
|
+
v_mallory uuid := gen_random_uuid();
|
|
57
|
+
v_board uuid;
|
|
58
|
+
v_code_member text; v_code_maxed text; v_code_public text; v_code_mallory text;
|
|
59
|
+
v_code_harry text; v_code_revoke text; v_code_revoke_authz text; v_code_enroll text;
|
|
60
|
+
v_code_expired text := 'expired-code-' || v_suffix;
|
|
61
|
+
v_prov_iss text := 'https://roomlink-fed-' || v_suffix || '.test';
|
|
62
|
+
begin
|
|
63
|
+
v_alice_user := public.ensure_github_user('rl-alice-' || v_suffix, 'rl-alice-' || v_suffix || '@example.test', 'rl-alice-' || v_suffix, 'RL Alice');
|
|
64
|
+
v_bob_user := public.ensure_github_user('rl-bob-' || v_suffix, 'rl-bob-' || v_suffix || '@example.test', 'rl-bob-' || v_suffix, 'RL Bob');
|
|
65
|
+
v_carol_user := public.ensure_github_user('rl-carol-' || v_suffix, 'rl-carol-' || v_suffix || '@example.test', 'rl-carol-' || v_suffix, 'RL Carol');
|
|
66
|
+
v_mallory_user := public.ensure_github_user('rl-mallory-' || v_suffix, 'rl-mallory-' || v_suffix || '@example.test', 'rl-mallory-' || v_suffix, 'RL Mallory');
|
|
67
|
+
|
|
68
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
69
|
+
values
|
|
70
|
+
(v_alice, 'agent', 'rl-alice-' || v_suffix, 'RL Alice', 'active', v_alice_user, v_alice_user),
|
|
71
|
+
(v_bob, 'agent', 'rl-bob-' || v_suffix, 'RL Bob', 'active', v_bob_user, v_bob_user),
|
|
72
|
+
(v_carol, 'agent', 'rl-carol-' || v_suffix, 'RL Carol', 'active', v_carol_user, v_carol_user),
|
|
73
|
+
-- mallory is SUSPENDED but still holds an admin membership + a valid auth user:
|
|
74
|
+
-- current_agent_id() filters status='active' (fail-closed redeem), and R-LINK-2
|
|
75
|
+
-- refuses redeeming her outstanding link.
|
|
76
|
+
(v_mallory, 'agent', 'rl-mallory-' || v_suffix, 'RL Mallory', 'suspended', v_mallory_user, v_mallory_user),
|
|
77
|
+
(v_carol2, 'agent', 'rl-carol2-' || v_suffix, 'RL Carol2', 'active', null, null),
|
|
78
|
+
(v_carol3, 'agent', 'rl-carol3-' || v_suffix, 'RL Carol3', 'active', null, null),
|
|
79
|
+
(v_dave, 'agent', 'rl-dave-' || v_suffix, 'RL Dave', 'active', null, null),
|
|
80
|
+
(v_erin, 'agent', 'rl-erin-' || v_suffix, 'RL Erin', 'active', null, null),
|
|
81
|
+
(v_frank, 'agent', 'rl-frank-' || v_suffix, 'RL Frank', 'active', null, null),
|
|
82
|
+
(v_gus, 'agent', 'rl-gus-' || v_suffix, 'RL Gus', 'active', null, null),
|
|
83
|
+
(v_grimm, 'agent', 'rl-grimm-' || v_suffix, 'RL Grimm', 'active', null, null),
|
|
84
|
+
(v_harry, 'agent', 'rl-harry-' || v_suffix, 'RL Harry', 'active', null, null);
|
|
85
|
+
|
|
86
|
+
-- Alice creates board B (service_role null-actor -> supplied arg honored): create_board
|
|
87
|
+
-- seeds Alice's joined owner membership.
|
|
88
|
+
select board_id into v_board
|
|
89
|
+
from agent_board.create_board(v_alice, 'roomlink-board-' || v_suffix, 'ROOMLINK Board', 'roomlink test', '{}'::jsonb, 'private');
|
|
90
|
+
|
|
91
|
+
-- Pre-existing memberships used by the redemption cases.
|
|
92
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
93
|
+
values
|
|
94
|
+
(v_board, v_dave, 'joined', v_alice, 'admin'), -- no-downgrade target
|
|
95
|
+
(v_board, v_grimm, 'joined', v_alice, 'observer'), -- too-low to mint member links
|
|
96
|
+
(v_board, v_harry, 'joined', v_alice, 'admin'), -- mints an admin link, later demoted
|
|
97
|
+
(v_board, v_mallory, 'joined', v_alice, 'admin'); -- mints a link, agent suspended
|
|
98
|
+
-- Erin was kicked (removed): the active partial index does not cover this row, so a
|
|
99
|
+
-- redeem must explicitly refuse rather than seat a fresh joined row.
|
|
100
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
|
|
101
|
+
values (v_board, v_erin, 'removed', v_alice, 'member', now());
|
|
102
|
+
|
|
103
|
+
-- Mint links (service_role context: default role '', session_user postgres).
|
|
104
|
+
select code into v_code_member
|
|
105
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
106
|
+
select code into v_code_maxed
|
|
107
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', 1, null, v_alice, '{}'::jsonb);
|
|
108
|
+
select code into v_code_public
|
|
109
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
110
|
+
select code into v_code_revoke
|
|
111
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
112
|
+
select code into v_code_revoke_authz
|
|
113
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
114
|
+
select code into v_code_enroll
|
|
115
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_alice, '{}'::jsonb);
|
|
116
|
+
-- Mallory (admin membership) mints a member link; her agent is suspended.
|
|
117
|
+
select code into v_code_mallory
|
|
118
|
+
from agent_board.create_invite_link(v_board, 'member', 'joined', null, null, v_mallory, '{}'::jsonb);
|
|
119
|
+
-- Harry (admin) mints an ADMIN link (use cap required); harry is then demoted.
|
|
120
|
+
select code into v_code_harry
|
|
121
|
+
from agent_board.create_invite_link(v_board, 'admin', 'joined', 2, null, v_harry, '{}'::jsonb);
|
|
122
|
+
update agent_board.board_memberships bm set role = 'member'
|
|
123
|
+
where bm.board_id = v_board and bm.agent_id = v_harry;
|
|
124
|
+
|
|
125
|
+
-- An already-expired token (direct insert; create_invite_link rejects a past TTL).
|
|
126
|
+
insert into agent_board.board_invite_tokens(code, board_id, preset_role, preset_membership_state, expires_at, created_by_agent_id)
|
|
127
|
+
values (v_code_expired, v_board, 'member', 'joined', now() - interval '1 day', v_alice);
|
|
128
|
+
|
|
129
|
+
-- Approved identity provider for the enroll-binding cases.
|
|
130
|
+
insert into agent_board.identity_providers
|
|
131
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
|
|
132
|
+
values (v_prov_iss, 'message-board', true, true, '{}', '{}', '{agent,human}');
|
|
133
|
+
|
|
134
|
+
insert into _lf values (
|
|
135
|
+
v_board, v_alice, v_bob, v_carol, v_carol2, v_carol3, v_dave, v_erin, v_frank,
|
|
136
|
+
v_gus, v_grimm, v_harry, v_mallory,
|
|
137
|
+
v_alice_user, v_bob_user, v_carol_user, v_mallory_user,
|
|
138
|
+
v_code_member, v_code_maxed, v_code_public, v_code_mallory, v_code_harry,
|
|
139
|
+
v_code_revoke, v_code_revoke_authz, v_code_enroll, v_code_expired,
|
|
140
|
+
v_prov_iss, 'portal|' || v_suffix, 'rl-enrollee-' || v_suffix,
|
|
141
|
+
'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0',
|
|
142
|
+
'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
|
|
143
|
+
'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk',
|
|
144
|
+
'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA'
|
|
145
|
+
);
|
|
146
|
+
end
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
-- === A. create validation ============================================== (7)
|
|
150
|
+
-- 1
|
|
151
|
+
select throws_ok(
|
|
152
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'owner', 'joined', 1, null, %L::uuid)$q$, board, alice) from _lf),
|
|
153
|
+
'22023', 'owner presets are not invite-linkable',
|
|
154
|
+
'create_invite_link forbids the owner preset (R-LINK-3)');
|
|
155
|
+
-- 2
|
|
156
|
+
select throws_ok(
|
|
157
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'admin', 'joined', null, null, %L::uuid)$q$, board, alice) from _lf),
|
|
158
|
+
'22023', 'admin invite links require a bounded p_max_uses',
|
|
159
|
+
'admin preset requires a use cap (R-LINK-3)');
|
|
160
|
+
-- 3
|
|
161
|
+
select throws_ok(
|
|
162
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'admin', 'joined', 5, now() + interval '10 days', %L::uuid)$q$, board, alice) from _lf),
|
|
163
|
+
'22023', 'admin invite links require a TTL of at most 7 days',
|
|
164
|
+
'admin preset requires a <=7d TTL (R-LINK-3)');
|
|
165
|
+
-- 4
|
|
166
|
+
select throws_ok(
|
|
167
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'member', 'joined', null, null, %L::uuid)$q$, board, grimm) from _lf),
|
|
168
|
+
'42501', 'preset role exceeds creator role or board authority ceiling',
|
|
169
|
+
'create is grantor-bounded: an observer cannot mint a member link');
|
|
170
|
+
-- 5
|
|
171
|
+
select is((select length(code_member) from _lf), 43, 'the minted code is a 43-char base64url (256-bit) string');
|
|
172
|
+
-- 6
|
|
173
|
+
select is(
|
|
174
|
+
(select preset_role from agent_board.board_invite_tokens t join _lf f on t.code = f.code_member),
|
|
175
|
+
'member', 'the minted member link stores preset_role=member');
|
|
176
|
+
-- 7
|
|
177
|
+
select isnt((select code_member from _lf), (select code_maxed from _lf), 'distinct mints produce distinct codes (uniqueness)');
|
|
178
|
+
|
|
179
|
+
-- === B. create actor-guard ============================================= (1)
|
|
180
|
+
-- 8
|
|
181
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
182
|
+
select set_config('request.jwt.claim.sub', (select bob_user from _lf)::text, true);
|
|
183
|
+
select throws_ok(
|
|
184
|
+
(select format($q$select agent_board.create_invite_link(%L::uuid, 'member', 'joined', null, null, %L::uuid)$q$, board, alice) from _lf),
|
|
185
|
+
'42501', 'p_created_by_agent_id must match current_agent_id()',
|
|
186
|
+
'actor-guard: authenticated Bob cannot mint a link as Alice');
|
|
187
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
188
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
189
|
+
|
|
190
|
+
-- === C. redeem basics ================================================== (3)
|
|
191
|
+
-- 9
|
|
192
|
+
select is(
|
|
193
|
+
(select role from agent_board._redeem_invite_for_agent((select code_member from _lf), (select carol from _lf))),
|
|
194
|
+
'member', 'redeem seats Carol with the member role');
|
|
195
|
+
-- 10
|
|
196
|
+
select is(
|
|
197
|
+
(select count(*)::int from agent_board.board_memberships bm join _lf f on bm.board_id = f.board and bm.agent_id = f.carol
|
|
198
|
+
where bm.membership_state in ('joined','subscribed')),
|
|
199
|
+
1, 'Carol holds exactly one active membership after redeem');
|
|
200
|
+
-- 11
|
|
201
|
+
select is(
|
|
202
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_member),
|
|
203
|
+
1, 'the member link uses_count incremented to 1');
|
|
204
|
+
|
|
205
|
+
-- === D. idempotency + exhaustion (maxed link) ========================== (5)
|
|
206
|
+
-- 12
|
|
207
|
+
select is(
|
|
208
|
+
(select role from agent_board._redeem_invite_for_agent((select code_maxed from _lf), (select frank from _lf))),
|
|
209
|
+
'member', 'first redeem of the maxed link seats Frank');
|
|
210
|
+
-- 13
|
|
211
|
+
select is(
|
|
212
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_maxed),
|
|
213
|
+
1, 'the maxed link uses_count is 1 after Frank');
|
|
214
|
+
-- 14
|
|
215
|
+
select lives_ok(
|
|
216
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_maxed, frank) from _lf),
|
|
217
|
+
'replay by the same agent on a maxed link is an idempotent no-op (M1), not 42501');
|
|
218
|
+
-- 15
|
|
219
|
+
select is(
|
|
220
|
+
(select uses_count from agent_board.board_invite_tokens t join _lf f on t.code = f.code_maxed),
|
|
221
|
+
1, 'the idempotent replay did NOT inflate uses_count');
|
|
222
|
+
-- 16
|
|
223
|
+
select throws_ok(
|
|
224
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_maxed, gus) from _lf),
|
|
225
|
+
'42501', 'invite is not usable',
|
|
226
|
+
'a DIFFERENT agent redeeming the exhausted link is refused (uniform 42501)');
|
|
227
|
+
|
|
228
|
+
-- === E. no-downgrade (rank-max) ======================================== (2)
|
|
229
|
+
-- 17
|
|
230
|
+
select is(
|
|
231
|
+
(select role from agent_board._redeem_invite_for_agent((select code_member from _lf), (select dave from _lf))),
|
|
232
|
+
'admin', 'redeeming a member link never downgrades Dave''s existing admin role (R-LINK-1)');
|
|
233
|
+
-- 18
|
|
234
|
+
select is(
|
|
235
|
+
(select role from agent_board.board_memberships bm join _lf f on bm.board_id = f.board and bm.agent_id = f.dave
|
|
236
|
+
where bm.membership_state in ('joined','subscribed')),
|
|
237
|
+
'admin', 'Dave''s stored membership role remains admin');
|
|
238
|
+
|
|
239
|
+
-- === F. kicked-agent refusal (R-LINK-7) ================================ (1)
|
|
240
|
+
-- 19
|
|
241
|
+
select throws_ok(
|
|
242
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_member, erin) from _lf),
|
|
243
|
+
'42501', 'invite is not usable',
|
|
244
|
+
'a removed (kicked) agent cannot re-enter via an invite link (R-LINK-7)');
|
|
245
|
+
|
|
246
|
+
-- === G. suspended-creator refusal (R-LINK-2) =========================== (1)
|
|
247
|
+
-- 20
|
|
248
|
+
select throws_ok(
|
|
249
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_mallory, carol2) from _lf),
|
|
250
|
+
'42501', 'invite is not usable',
|
|
251
|
+
'a suspended creator''s outstanding link stops minting (R-LINK-2)');
|
|
252
|
+
|
|
253
|
+
-- === H. creator-ceiling re-bind ======================================== (1)
|
|
254
|
+
-- 21
|
|
255
|
+
select throws_ok(
|
|
256
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_harry, carol3) from _lf),
|
|
257
|
+
'42501', 'invite is not usable',
|
|
258
|
+
'a demoted creator''s admin link auto-degrades at redeem (re-bound ceiling)');
|
|
259
|
+
|
|
260
|
+
-- === I. expired ======================================================== (1)
|
|
261
|
+
-- 22
|
|
262
|
+
select throws_ok(
|
|
263
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_expired, gus) from _lf),
|
|
264
|
+
'42501', 'invite is not usable',
|
|
265
|
+
'an expired link is refused (uniform 42501)');
|
|
266
|
+
|
|
267
|
+
-- === J. revoke RPC ===================================================== (3)
|
|
268
|
+
-- 23
|
|
269
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
270
|
+
select set_config('request.jwt.claim.sub', (select alice_user from _lf)::text, true);
|
|
271
|
+
select lives_ok(
|
|
272
|
+
(select format($q$select agent_board.revoke_invite_link(%L)$q$, code_revoke) from _lf),
|
|
273
|
+
'the creator (Alice) revokes her own link');
|
|
274
|
+
-- 24
|
|
275
|
+
select ok(
|
|
276
|
+
(select revoked_at is not null from agent_board.board_invite_tokens t join _lf f on t.code = f.code_revoke),
|
|
277
|
+
'the revoked link carries a revoked_at timestamp');
|
|
278
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
279
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
280
|
+
-- 25
|
|
281
|
+
select throws_ok(
|
|
282
|
+
(select format($q$select agent_board._redeem_invite_for_agent(%L, %L::uuid)$q$, code_revoke, carol2) from _lf),
|
|
283
|
+
'42501', 'invite is not usable',
|
|
284
|
+
'a revoked link is refused at redeem (uniform 42501)');
|
|
285
|
+
|
|
286
|
+
-- === K. revoke authz =================================================== (1)
|
|
287
|
+
-- 26
|
|
288
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
289
|
+
select set_config('request.jwt.claim.sub', (select carol_user from _lf)::text, true);
|
|
290
|
+
select throws_ok(
|
|
291
|
+
(select format($q$select agent_board.revoke_invite_link(%L)$q$, code_revoke_authz) from _lf),
|
|
292
|
+
'42501', 'not authorized to revoke this invite',
|
|
293
|
+
'a plain member (Carol) cannot revoke someone else''s link');
|
|
294
|
+
|
|
295
|
+
-- === L. public redeem happy path ======================================= (1)
|
|
296
|
+
-- 27
|
|
297
|
+
select is(
|
|
298
|
+
(select role from agent_board.redeem_invite_link((select code_public from _lf))),
|
|
299
|
+
'member', 'the public redeem_invite_link resolves the actor via current_agent_id() and seats Carol');
|
|
300
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
301
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
302
|
+
|
|
303
|
+
-- === M. redeem actor fail-closed ======================================= (1)
|
|
304
|
+
-- 28
|
|
305
|
+
select set_config('request.jwt.claim.role', 'authenticated', true);
|
|
306
|
+
select set_config('request.jwt.claim.sub', (select mallory_user from _lf)::text, true);
|
|
307
|
+
select throws_ok(
|
|
308
|
+
(select format($q$select agent_board.redeem_invite_link(%L)$q$, code_public) from _lf),
|
|
309
|
+
'42501', 'current agent principal not resolved',
|
|
310
|
+
'a suspended principal (current_agent_id() null) cannot redeem-as-nobody');
|
|
311
|
+
select set_config('request.jwt.claim.role', '', true);
|
|
312
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
313
|
+
|
|
314
|
+
-- === N. internal core is not authenticated-callable ==================== (1)
|
|
315
|
+
-- 29
|
|
316
|
+
select function_privs_are(
|
|
317
|
+
'agent_board', '_redeem_invite_for_agent', array['text', 'uuid'],
|
|
318
|
+
'authenticated', array[]::text[],
|
|
319
|
+
'_redeem_invite_for_agent execute is revoked from authenticated (service_role-only core)');
|
|
320
|
+
|
|
321
|
+
-- === O. enroll-on-redeem binding ======================================= (5)
|
|
322
|
+
do $$
|
|
323
|
+
declare v_aid uuid; v_red boolean;
|
|
324
|
+
begin
|
|
325
|
+
-- Valid code: the enrollee lands in the board and invite_redeemed=true.
|
|
326
|
+
select e.agent_id, e.invite_redeemed into v_aid, v_red
|
|
327
|
+
from _lf f, agent_board.enroll_federated_identity(
|
|
328
|
+
f.prov_iss, 'message-board', f.prov_sub,
|
|
329
|
+
jsonb_build_object('sub', f.prov_sub), f.prov_slug,
|
|
330
|
+
f.pub1, 'raw_ed25519_base64url', f.fp1, true, null, 'agent', '{}', '{}', f.code_enroll) e;
|
|
331
|
+
insert into _er values ('X', v_aid, v_red);
|
|
332
|
+
|
|
333
|
+
-- Bad code: enrollment still succeeds, invite_redeemed=false, no membership.
|
|
334
|
+
select e.agent_id, e.invite_redeemed into v_aid, v_red
|
|
335
|
+
from _lf f, agent_board.enroll_federated_identity(
|
|
336
|
+
f.prov_iss, 'message-board', f.prov_sub || '-2',
|
|
337
|
+
jsonb_build_object('sub', f.prov_sub || '-2'), f.prov_slug || '-2',
|
|
338
|
+
f.pub2, 'raw_ed25519_base64url', f.fp2, true, null, 'agent', '{}', '{}', 'bogus-code-nonexistent') e;
|
|
339
|
+
insert into _er values ('Y', v_aid, v_red);
|
|
340
|
+
end
|
|
341
|
+
$$;
|
|
342
|
+
-- 30
|
|
343
|
+
select is((select invite_redeemed from _er where label = 'X'), true, 'enroll with a valid invite_code reports invite_redeemed=true');
|
|
344
|
+
-- 31
|
|
345
|
+
select is(
|
|
346
|
+
(select count(*)::int from agent_board.board_memberships bm, _lf f, _er r
|
|
347
|
+
where r.label = 'X' and bm.agent_id = r.agent_id and bm.board_id = f.board
|
|
348
|
+
and bm.membership_state in ('joined','subscribed')),
|
|
349
|
+
1, 'the enrollee was seated on the board by the redeemed code');
|
|
350
|
+
-- 32
|
|
351
|
+
select is(
|
|
352
|
+
(select status from agent_board.agents a join _er r on a.id = r.agent_id where r.label = 'Y'),
|
|
353
|
+
'active', 'enroll with a BAD invite_code still creates an active agent (soft-fail)');
|
|
354
|
+
-- 33
|
|
355
|
+
select is((select invite_redeemed from _er where label = 'Y'), false, 'a bad invite_code reports invite_redeemed=false');
|
|
356
|
+
-- 34
|
|
357
|
+
select is(
|
|
358
|
+
(select count(*)::int from agent_board.board_memberships bm, _lf f, _er r
|
|
359
|
+
where r.label = 'Y' and bm.agent_id = r.agent_id and bm.board_id = f.board),
|
|
360
|
+
0, 'the soft-failed enrollee gained no membership');
|
|
361
|
+
|
|
362
|
+
select finish();
|
|
363
|
+
|
|
364
|
+
rollback;
|
|
@@ -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;
|