@marktiderman/genesis-supabase 0.2.0 → 0.3.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/README.md +367 -23
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -103
- package/dist/index.js +60 -3
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +90 -6
- package/dist/native.js +70 -2
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- package/package.json +91 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
-- pgTAP test for 0003_genesis_apple_token_revocation.sql.
|
|
2
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
3
|
+
-- It reads only its own users and rows and rolls everything back. (The claim
|
|
4
|
+
-- cases push any other queued row out of reach first, inside this transaction.)
|
|
5
|
+
begin;
|
|
6
|
+
create extension if not exists pgtap with schema extensions;
|
|
7
|
+
select plan(45);
|
|
8
|
+
|
|
9
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
10
|
+
select ('00000000-0000-4000-8000-0000000003' || n)::uuid, 'authenticated', 'authenticated', 'genesis-apple-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
11
|
+
from unnest(array['a1', 'b2', 'c3']) n;
|
|
12
|
+
|
|
13
|
+
create function pg_temp.u(n text) returns uuid language sql as $$ select ('00000000-0000-4000-8000-0000000003' || n)::uuid $$;
|
|
14
|
+
create function pg_temp.queued(n text) returns bigint language sql as $$
|
|
15
|
+
select count(*) from private.genesis_apple_revocations where user_id = pg_temp.u(n)
|
|
16
|
+
$$;
|
|
17
|
+
|
|
18
|
+
-- The tables: private, row level security on, nothing granted -----------------
|
|
19
|
+
select ok((select relrowsecurity from pg_class where oid = 'private.genesis_apple_tokens'::regclass), 'genesis_apple_tokens has row level security on');
|
|
20
|
+
select ok((select relrowsecurity from pg_class where oid = 'private.genesis_apple_revocations'::regclass), 'genesis_apple_revocations has row level security on');
|
|
21
|
+
select ok(
|
|
22
|
+
not exists (
|
|
23
|
+
select 1
|
|
24
|
+
from unnest(array['anon', 'authenticated']) r(role),
|
|
25
|
+
unnest(array['private.genesis_apple_tokens', 'private.genesis_apple_revocations']) t(tbl),
|
|
26
|
+
unnest(array['select', 'insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p(priv)
|
|
27
|
+
where has_table_privilege(r.role, t.tbl, p.priv)
|
|
28
|
+
),
|
|
29
|
+
'anon and authenticated have no privilege on either table'
|
|
30
|
+
);
|
|
31
|
+
select ok(
|
|
32
|
+
not exists (
|
|
33
|
+
select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
34
|
+
where c.oid in ('private.genesis_apple_tokens'::regclass, 'private.genesis_apple_revocations'::regclass) and a.grantee = 0
|
|
35
|
+
),
|
|
36
|
+
'public has no grant on either table'
|
|
37
|
+
);
|
|
38
|
+
select ok(not has_schema_privilege('anon', 'private', 'usage'), 'anon cannot use the private schema');
|
|
39
|
+
select ok(not has_schema_privilege('authenticated', 'private', 'usage'), 'authenticated cannot use the private schema');
|
|
40
|
+
|
|
41
|
+
-- The functions: security definer, empty search_path, service_role only ---------
|
|
42
|
+
create temp table genesis_fns (sig regprocedure, public_api boolean);
|
|
43
|
+
insert into genesis_fns values
|
|
44
|
+
('private.genesis_apple_queue_revocation()'::regprocedure, false),
|
|
45
|
+
('public.genesis_apple_token_save(uuid, text, text)'::regprocedure, true),
|
|
46
|
+
('public.genesis_apple_revocations_claim(integer)'::regprocedure, true),
|
|
47
|
+
('public.genesis_apple_revocation_done(bigint)'::regprocedure, true),
|
|
48
|
+
('public.genesis_apple_revocation_failed(bigint, text)'::regprocedure, true);
|
|
49
|
+
|
|
50
|
+
select ok(
|
|
51
|
+
(select bool_and(p.prosecdef and p.proconfig @> array['search_path=""']) from genesis_fns f join pg_proc p on p.oid = f.sig),
|
|
52
|
+
'every function is security definer with an empty search_path'
|
|
53
|
+
);
|
|
54
|
+
select ok(
|
|
55
|
+
(select bool_and(p.proowner::regrole::text = 'postgres') from genesis_fns f join pg_proc p on p.oid = f.sig),
|
|
56
|
+
'every function is owned by postgres, as hosted migrations run'
|
|
57
|
+
);
|
|
58
|
+
select ok(
|
|
59
|
+
not exists (select 1 from genesis_fns f where has_function_privilege('anon', f.sig, 'execute')),
|
|
60
|
+
'anon can execute none of them'
|
|
61
|
+
);
|
|
62
|
+
select ok(
|
|
63
|
+
not exists (select 1 from genesis_fns f where has_function_privilege('authenticated', f.sig, 'execute')),
|
|
64
|
+
'authenticated can execute none of them'
|
|
65
|
+
);
|
|
66
|
+
select ok(
|
|
67
|
+
not exists (
|
|
68
|
+
select 1 from genesis_fns f join pg_proc p on p.oid = f.sig, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
69
|
+
where a.grantee = 0 and a.privilege_type = 'EXECUTE'
|
|
70
|
+
),
|
|
71
|
+
'public has no execute grant on any of them'
|
|
72
|
+
);
|
|
73
|
+
select ok(
|
|
74
|
+
(select bool_and(has_function_privilege('service_role', f.sig, 'execute')) from genesis_fns f where f.public_api),
|
|
75
|
+
'service_role can execute the four public functions'
|
|
76
|
+
);
|
|
77
|
+
select ok(
|
|
78
|
+
not has_function_privilege('service_role', 'private.genesis_apple_queue_revocation()', 'execute'),
|
|
79
|
+
'service_role cannot call the trigger function directly'
|
|
80
|
+
);
|
|
81
|
+
select is(
|
|
82
|
+
(select count(*)::int from pg_trigger where tgrelid = 'auth.users'::regclass and tgname = 'genesis_apple_queue_revocation' and not tgisinternal and tgenabled <> 'D'),
|
|
83
|
+
1,
|
|
84
|
+
'the delete trigger is on auth.users'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- A signed-in person can't reach a token, theirs or anyone's -----------------------
|
|
88
|
+
insert into private.genesis_apple_tokens (user_id, client_id, refresh_token) values (pg_temp.u('a1'), 'com.example.app', 'rt-a1-old');
|
|
89
|
+
|
|
90
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000003a1","role":"authenticated"}', true);
|
|
91
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000003a1', true);
|
|
92
|
+
set local role authenticated;
|
|
93
|
+
select throws_ok($$ select refresh_token from private.genesis_apple_tokens $$, '42501', null, 'authenticated cannot read the tokens table');
|
|
94
|
+
select throws_ok($$ select refresh_token from private.genesis_apple_revocations $$, '42501', null, 'authenticated cannot read the queue');
|
|
95
|
+
select throws_ok(
|
|
96
|
+
$$ select public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'com.example.app', 'planted') $$,
|
|
97
|
+
'42501', null, 'authenticated cannot save a token'
|
|
98
|
+
);
|
|
99
|
+
select throws_ok($$ select * from public.genesis_apple_revocations_claim(100) $$, '42501', null, 'authenticated cannot claim the queue');
|
|
100
|
+
select throws_ok($$ select public.genesis_apple_revocation_done(1) $$, '42501', null, 'authenticated cannot drop a queued token');
|
|
101
|
+
reset role;
|
|
102
|
+
|
|
103
|
+
set local role anon;
|
|
104
|
+
select throws_ok($$ select * from public.genesis_apple_revocations_claim(100) $$, '42501', null, 'anon cannot claim the queue');
|
|
105
|
+
reset role;
|
|
106
|
+
|
|
107
|
+
-- Saving (service_role, as the Edge Function) ------------------------------------
|
|
108
|
+
set local role service_role;
|
|
109
|
+
select is(public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003a1', 'com.example.app', 'rt-a1-new'), 'stored', 'a sign-in''s token is stored');
|
|
110
|
+
reset role;
|
|
111
|
+
select is(
|
|
112
|
+
(select array_agg(refresh_token) from private.genesis_apple_tokens where user_id = pg_temp.u('a1')),
|
|
113
|
+
array['rt-a1-new'],
|
|
114
|
+
'... replacing the earlier one for that client id'
|
|
115
|
+
);
|
|
116
|
+
set local role service_role;
|
|
117
|
+
select is(public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003a1', 'com.example.app.dev', 'rt-a1-dev'), 'stored', 'another client id keeps its own token');
|
|
118
|
+
select is(
|
|
119
|
+
public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003ff', 'com.example.app', 'rt-gone'),
|
|
120
|
+
'queued',
|
|
121
|
+
'a token for a login that is already gone goes to the queue'
|
|
122
|
+
);
|
|
123
|
+
select throws_ok(
|
|
124
|
+
$$ select public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'not a bundle id!', 'rt') $$,
|
|
125
|
+
'23514', null, 'a malformed client id is refused'
|
|
126
|
+
);
|
|
127
|
+
reset role;
|
|
128
|
+
create function pg_temp.save_error_text() returns text language plpgsql as $f$
|
|
129
|
+
declare msg text; det text;
|
|
130
|
+
begin
|
|
131
|
+
perform public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'com.example.app', repeat('secret-token-', 400));
|
|
132
|
+
return 'no error';
|
|
133
|
+
exception when others then
|
|
134
|
+
get stacked diagnostics msg = message_text, det = pg_exception_detail;
|
|
135
|
+
return coalesce(msg, '') || '|' || coalesce(det, '');
|
|
136
|
+
end $f$;
|
|
137
|
+
select ok(pg_temp.save_error_text() !~ 'secret-token', '... and a refused token never appears in the error');
|
|
138
|
+
select is(pg_temp.queued('ff'), 1::bigint, '... and is queued once');
|
|
139
|
+
|
|
140
|
+
-- Deleting the login queues its tokens -------------------------------------------
|
|
141
|
+
delete from auth.users where id = pg_temp.u('a1');
|
|
142
|
+
select is((select count(*) from private.genesis_apple_tokens where user_id = pg_temp.u('a1')), 0::bigint, 'deleting the login removes its stored tokens');
|
|
143
|
+
select is(
|
|
144
|
+
(select array_agg(refresh_token order by client_id) from private.genesis_apple_revocations where user_id = pg_temp.u('a1')),
|
|
145
|
+
array['rt-a1-new', 'rt-a1-dev'],
|
|
146
|
+
'... after queueing every one of them for revocation'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
-- A refused delete queues nothing ---------------------------------------------------
|
|
150
|
+
insert into private.genesis_apple_tokens (user_id, client_id, refresh_token) values (pg_temp.u('b2'), 'com.example.app', 'rt-b2');
|
|
151
|
+
create table public.genesis_test_apple_kept (person uuid references auth.users (id));
|
|
152
|
+
insert into public.genesis_test_apple_kept values (pg_temp.u('b2'));
|
|
153
|
+
select throws_ok($$ delete from auth.users where id = '00000000-0000-4000-8000-0000000003b2' $$, '23503', null, 'a delete refused by a foreign key fails');
|
|
154
|
+
select is(pg_temp.queued('b2'), 0::bigint, '... and queues nothing');
|
|
155
|
+
select is((select count(*) from private.genesis_apple_tokens where user_id = pg_temp.u('b2')), 1::bigint, '... and keeps the stored token');
|
|
156
|
+
|
|
157
|
+
-- Claiming, retrying and finishing ------------------------------------------------
|
|
158
|
+
update private.genesis_apple_revocations set next_attempt_at = 'infinity' where user_id not in (pg_temp.u('a1'), pg_temp.u('ff'));
|
|
159
|
+
|
|
160
|
+
create temp table claimed (id bigint, client_id text, refresh_token text, attempts integer);
|
|
161
|
+
grant insert on claimed to service_role;
|
|
162
|
+
set local role service_role;
|
|
163
|
+
insert into claimed select * from public.genesis_apple_revocations_claim(100);
|
|
164
|
+
reset role;
|
|
165
|
+
select is(
|
|
166
|
+
(select array_agg(c.refresh_token order by c.refresh_token) from claimed c),
|
|
167
|
+
array['rt-a1-dev', 'rt-a1-new', 'rt-gone'],
|
|
168
|
+
'a claim hands out every due revocation'
|
|
169
|
+
);
|
|
170
|
+
select ok((select bool_and(c.attempts = 1) from claimed c), '... counting the attempt');
|
|
171
|
+
select ok(
|
|
172
|
+
(select bool_and(r.next_attempt_at > now()) from private.genesis_apple_revocations r join claimed c using (id)),
|
|
173
|
+
'... and leasing each row until its next retry'
|
|
174
|
+
);
|
|
175
|
+
create temp table ids as select (select id from claimed where refresh_token = 'rt-a1-dev') as failing, (select id from claimed where refresh_token = 'rt-a1-new') as revoked;
|
|
176
|
+
set local role service_role;
|
|
177
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 0::bigint, 'a second claim takes none of the leased rows');
|
|
178
|
+
reset role;
|
|
179
|
+
select set_config('genesis.failing', (select failing::text from ids), true);
|
|
180
|
+
select set_config('genesis.revoked', (select revoked::text from ids), true);
|
|
181
|
+
set local role service_role;
|
|
182
|
+
select is(public.genesis_apple_revocation_failed(current_setting('genesis.failing')::bigint, repeat('x', 300)), 'retry', 'a failure is kept for a retry');
|
|
183
|
+
select public.genesis_apple_revocation_done(current_setting('genesis.revoked')::bigint);
|
|
184
|
+
reset role;
|
|
185
|
+
select is(
|
|
186
|
+
(select length(last_error) from private.genesis_apple_revocations where refresh_token = 'rt-a1-dev'),
|
|
187
|
+
200,
|
|
188
|
+
'... with its reason cut to 200 characters'
|
|
189
|
+
);
|
|
190
|
+
select is(
|
|
191
|
+
(select count(*) from private.genesis_apple_revocations where refresh_token = 'rt-a1-new'),
|
|
192
|
+
0::bigint,
|
|
193
|
+
'a revoked token is forgotten'
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
-- Backoff: one minute, doubling, at most a day.
|
|
197
|
+
update private.genesis_apple_revocations set next_attempt_at = now() - interval '1 second', attempts = 30 where refresh_token = 'rt-a1-dev';
|
|
198
|
+
set local role service_role;
|
|
199
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 1::bigint, 'a due retry is claimed again');
|
|
200
|
+
reset role;
|
|
201
|
+
select ok(
|
|
202
|
+
(select next_attempt_at between now() + interval '23 hours 59 minutes' and now() + interval '1 day' from private.genesis_apple_revocations where refresh_token = 'rt-a1-dev'),
|
|
203
|
+
'retries are at most a day apart'
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
-- Giving up: after the 30th attempt the token goes and the row stays as the record.
|
|
207
|
+
set local role service_role;
|
|
208
|
+
select is(public.genesis_apple_revocation_failed(current_setting('genesis.failing')::bigint, 'check: invalid_client (HTTP 400)'), 'gave_up', 'the 30th failed attempt gives up');
|
|
209
|
+
select public.genesis_apple_revocation_done(current_setting('genesis.failing')::bigint);
|
|
210
|
+
reset role;
|
|
211
|
+
select ok(
|
|
212
|
+
(select refresh_token is null and last_error like 'gave_up after 31 attempts: check: invalid_client%' from private.genesis_apple_revocations where id = current_setting('genesis.failing')::bigint),
|
|
213
|
+
'... dropping the token and keeping the row, which done() leaves alone'
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
-- An Apple login with no stored token leaves a record -------------------------------
|
|
217
|
+
insert into auth.identities (provider_id, user_id, identity_data, provider)
|
|
218
|
+
values ('apple-sub-c3', pg_temp.u('c3'), '{"sub": "apple-sub-c3"}', 'apple');
|
|
219
|
+
delete from auth.users where id = pg_temp.u('c3');
|
|
220
|
+
select ok(
|
|
221
|
+
(select count(*) = 1 and bool_and(refresh_token is null and client_id is null and last_error like 'no_token:%') from private.genesis_apple_revocations where user_id = pg_temp.u('c3')),
|
|
222
|
+
'deleting an Apple login with no stored token records that nothing was revoked'
|
|
223
|
+
);
|
|
224
|
+
update private.genesis_apple_revocations set next_attempt_at = now() - interval '1 second' where user_id = pg_temp.u('c3');
|
|
225
|
+
set local role service_role;
|
|
226
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 0::bigint, '... and a claim never hands out a row without a token');
|
|
227
|
+
reset role;
|
|
228
|
+
|
|
229
|
+
select * from finish();
|
|
230
|
+
rollback;
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
-- @feat:tenant-features
|
|
2
|
+
-- pgTAP test for 0004_genesis_tenant_features.sql.
|
|
3
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
4
|
+
-- It writes only its own users, tenants and hooks and rolls everything back.
|
|
5
|
+
-- If your app already has the hooks (`private.genesis_tenant_admin`,
|
|
6
|
+
-- `private.genesis_tenant_member`), they are dropped and replaced inside this
|
|
7
|
+
-- transaction only.
|
|
8
|
+
--
|
|
9
|
+
-- Signing in: auth.uid() reads request.jwt.claims (and the legacy
|
|
10
|
+
-- request.jwt.claim.sub on older images), so pg_temp.sign_in sets both.
|
|
11
|
+
begin;
|
|
12
|
+
create extension if not exists pgtap with schema extensions;
|
|
13
|
+
select plan(65);
|
|
14
|
+
|
|
15
|
+
-- People: a1 and e5 admins of tenant 1, b2 member of tenant 1, c3 admin of tenant 2,
|
|
16
|
+
-- d4 nobody's admin but the owner of tenant 3.
|
|
17
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
18
|
+
select ('00000000-0000-4000-8000-0000000000' || n)::uuid, 'authenticated', 'authenticated', 'genesis-tenant-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
19
|
+
from unnest(array['a1', 'b2', 'c3', 'd4', 'e5']) n;
|
|
20
|
+
|
|
21
|
+
create function pg_temp.person(n text) returns uuid language sql immutable as $$ select ('00000000-0000-4000-8000-0000000000' || n)::uuid $$;
|
|
22
|
+
create function pg_temp.tenant(n int) returns uuid language sql immutable as $$ select ('00000000-0000-4000-9000-00000000000' || n)::uuid $$;
|
|
23
|
+
create function pg_temp.sign_in(n text) returns void language plpgsql as $$
|
|
24
|
+
begin
|
|
25
|
+
perform set_config('request.jwt.claims', json_build_object('sub', pg_temp.person(n), 'role', 'authenticated')::text, true);
|
|
26
|
+
perform set_config('request.jwt.claim.sub', pg_temp.person(n)::text, true);
|
|
27
|
+
end $$;
|
|
28
|
+
grant execute on function pg_temp.person(text), pg_temp.tenant(int), pg_temp.sign_in(text) to anon, authenticated, service_role;
|
|
29
|
+
|
|
30
|
+
-- An app's tenant table, with the foreign keys the migration's header recommends.
|
|
31
|
+
-- A tenant owned by a person goes when they do (a personal workspace).
|
|
32
|
+
create table public.genesis_test_teams (id uuid primary key, owner uuid references auth.users (id) on delete cascade);
|
|
33
|
+
insert into public.genesis_test_teams values (pg_temp.tenant(1), null), (pg_temp.tenant(2), null), (pg_temp.tenant(3), pg_temp.person('d4'));
|
|
34
|
+
alter table public.tenant_features add foreign key (tenant_id) references public.genesis_test_teams (id) on delete cascade;
|
|
35
|
+
alter table public.tenant_feature_changes add foreign key (tenant_id) references public.genesis_test_teams (id) on delete cascade;
|
|
36
|
+
|
|
37
|
+
drop function if exists private.genesis_tenant_admin(uuid, uuid);
|
|
38
|
+
drop function if exists private.genesis_tenant_member(uuid, uuid);
|
|
39
|
+
|
|
40
|
+
-- Shape -------------------------------------------------------------------------
|
|
41
|
+
select has_table('public', 'tenant_features', 'tenant_features exists');
|
|
42
|
+
select col_is_pk('public', 'tenant_features', array['tenant_id', 'feature_id'], 'one switch per tenant and feature');
|
|
43
|
+
select has_table('public', 'tenant_feature_changes', 'tenant_feature_changes exists');
|
|
44
|
+
select ok((select relrowsecurity from pg_class where oid = 'public.tenant_features'::regclass), 'RLS is on for the switches');
|
|
45
|
+
select ok((select relrowsecurity from pg_class where oid = 'public.tenant_feature_changes'::regclass), 'RLS is on for the history');
|
|
46
|
+
select is(
|
|
47
|
+
(select count(*)::int from pg_policy where polrelid in ('public.tenant_features'::regclass, 'public.tenant_feature_changes'::regclass)),
|
|
48
|
+
0,
|
|
49
|
+
'no policies: the tables are reached only through the functions'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
-- Privileges ------------------------------------------------------------------------
|
|
53
|
+
select ok(
|
|
54
|
+
not has_table_privilege('anon', 'public.tenant_features', 'select,insert,update,delete,truncate')
|
|
55
|
+
and not has_table_privilege('authenticated', 'public.tenant_features', 'select,insert,update,delete,truncate'),
|
|
56
|
+
'anon and authenticated have no privilege on the switches'
|
|
57
|
+
);
|
|
58
|
+
select ok(
|
|
59
|
+
not has_table_privilege('anon', 'public.tenant_feature_changes', 'select,insert,update,delete,truncate')
|
|
60
|
+
and not has_table_privilege('authenticated', 'public.tenant_feature_changes', 'select,insert,update,delete,truncate'),
|
|
61
|
+
'... nor on the history'
|
|
62
|
+
);
|
|
63
|
+
select ok(
|
|
64
|
+
not exists (
|
|
65
|
+
select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
66
|
+
where c.oid in ('public.tenant_features'::regclass, 'public.tenant_feature_changes'::regclass) and a.grantee = 0
|
|
67
|
+
),
|
|
68
|
+
'public has no grant on either table'
|
|
69
|
+
);
|
|
70
|
+
select ok(
|
|
71
|
+
has_table_privilege('service_role', 'public.tenant_features', 'insert')
|
|
72
|
+
and has_table_privilege('service_role', 'public.tenant_features', 'update')
|
|
73
|
+
and has_table_privilege('service_role', 'public.tenant_features', 'delete'),
|
|
74
|
+
'the service role writes the switches'
|
|
75
|
+
);
|
|
76
|
+
select ok(
|
|
77
|
+
not has_table_privilege('service_role', 'public.tenant_feature_changes', 'insert')
|
|
78
|
+
and not has_table_privilege('service_role', 'public.tenant_feature_changes', 'update')
|
|
79
|
+
and has_table_privilege('service_role', 'public.tenant_feature_changes', 'delete'),
|
|
80
|
+
'the service role can purge the history but never write it'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
select ok(
|
|
84
|
+
(select bool_and(prosecdef and proconfig @> array['search_path=""'] and proowner::regrole::text = 'postgres') from pg_proc
|
|
85
|
+
where oid in (
|
|
86
|
+
'public.set_tenant_feature(uuid, text, boolean, text)'::regprocedure,
|
|
87
|
+
'public.tenant_features_off(uuid)'::regprocedure,
|
|
88
|
+
'public.tenant_feature_settings(uuid)'::regprocedure,
|
|
89
|
+
'public.tenant_feature_history(uuid, integer)'::regprocedure,
|
|
90
|
+
'private.tenant_feature_on(uuid, text)'::regprocedure,
|
|
91
|
+
'private.genesis_is_tenant_admin(uuid)'::regprocedure,
|
|
92
|
+
'private.genesis_is_tenant_member(uuid)'::regprocedure,
|
|
93
|
+
'private.genesis_record_tenant_feature_change()'::regprocedure
|
|
94
|
+
)),
|
|
95
|
+
'every function is security definer, owned by postgres, with an empty search_path'
|
|
96
|
+
);
|
|
97
|
+
select ok(
|
|
98
|
+
not has_function_privilege('anon', 'public.set_tenant_feature(uuid, text, boolean, text)', 'execute')
|
|
99
|
+
and not has_function_privilege('anon', 'public.tenant_features_off(uuid)', 'execute')
|
|
100
|
+
and not has_function_privilege('anon', 'public.tenant_feature_settings(uuid)', 'execute')
|
|
101
|
+
and not has_function_privilege('anon', 'public.tenant_feature_history(uuid, integer)', 'execute'),
|
|
102
|
+
'anon executes none of the public functions'
|
|
103
|
+
);
|
|
104
|
+
select ok(
|
|
105
|
+
has_function_privilege('authenticated', 'public.set_tenant_feature(uuid, text, boolean, text)', 'execute')
|
|
106
|
+
and has_function_privilege('authenticated', 'public.tenant_features_off(uuid)', 'execute')
|
|
107
|
+
and has_function_privilege('authenticated', 'public.tenant_feature_settings(uuid)', 'execute')
|
|
108
|
+
and has_function_privilege('authenticated', 'public.tenant_feature_history(uuid, integer)', 'execute'),
|
|
109
|
+
'authenticated executes them (each asks the hook)'
|
|
110
|
+
);
|
|
111
|
+
select ok(
|
|
112
|
+
not exists (
|
|
113
|
+
select 1 from pg_proc p, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
114
|
+
where p.oid in (
|
|
115
|
+
'public.set_tenant_feature(uuid, text, boolean, text)'::regprocedure,
|
|
116
|
+
'public.tenant_features_off(uuid)'::regprocedure,
|
|
117
|
+
'public.tenant_feature_settings(uuid)'::regprocedure,
|
|
118
|
+
'public.tenant_feature_history(uuid, integer)'::regprocedure,
|
|
119
|
+
'private.tenant_feature_on(uuid, text)'::regprocedure,
|
|
120
|
+
'private.genesis_is_tenant_admin(uuid)'::regprocedure,
|
|
121
|
+
'private.genesis_is_tenant_member(uuid)'::regprocedure,
|
|
122
|
+
'private.genesis_record_tenant_feature_change()'::regprocedure
|
|
123
|
+
) and a.grantee = 0 and a.privilege_type = 'EXECUTE'
|
|
124
|
+
),
|
|
125
|
+
'public executes none of them'
|
|
126
|
+
);
|
|
127
|
+
select ok(
|
|
128
|
+
not has_function_privilege('anon', 'private.genesis_is_tenant_admin(uuid)', 'execute')
|
|
129
|
+
and not has_function_privilege('authenticated', 'private.genesis_is_tenant_admin(uuid)', 'execute')
|
|
130
|
+
and not has_function_privilege('authenticated', 'private.genesis_is_tenant_member(uuid)', 'execute')
|
|
131
|
+
and not has_function_privilege('authenticated', 'private.genesis_record_tenant_feature_change()', 'execute'),
|
|
132
|
+
'the hook checks and the trigger are not executable by anon or authenticated'
|
|
133
|
+
);
|
|
134
|
+
select ok(
|
|
135
|
+
not has_function_privilege('anon', 'private.tenant_feature_on(uuid, text)', 'execute')
|
|
136
|
+
and not has_function_privilege('authenticated', 'private.tenant_feature_on(uuid, text)', 'execute'),
|
|
137
|
+
'the SQL gate (no member check) is for definer functions, not anon or authenticated'
|
|
138
|
+
);
|
|
139
|
+
select ok(
|
|
140
|
+
not has_sequence_privilege('anon', 'public.tenant_feature_changes_id_seq', 'usage,select,update')
|
|
141
|
+
and not has_sequence_privilege('authenticated', 'public.tenant_feature_changes_id_seq', 'usage,select,update'),
|
|
142
|
+
'the history''s id sequence is not anon''s or authenticated''s'
|
|
143
|
+
);
|
|
144
|
+
select ok(
|
|
145
|
+
not has_table_privilege('service_role', 'public.tenant_features', 'truncate,references,trigger'),
|
|
146
|
+
'the service role cannot truncate the switches (which would skip the history)'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
-- Without the hook, nobody may change anything --------------------------------------
|
|
150
|
+
select pg_temp.sign_in('a1');
|
|
151
|
+
set local role authenticated;
|
|
152
|
+
select throws_ok(
|
|
153
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'Not yet') $$,
|
|
154
|
+
'42501', 'set_tenant_feature: nobody may change tenant features yet',
|
|
155
|
+
'without private.genesis_tenant_admin, set_tenant_feature refuses (fails closed)'
|
|
156
|
+
);
|
|
157
|
+
select throws_ok(
|
|
158
|
+
$$ select * from public.tenant_features_off(pg_temp.tenant(1)) $$,
|
|
159
|
+
'42501', null,
|
|
160
|
+
'... and nobody may read a tenant''s switches'
|
|
161
|
+
);
|
|
162
|
+
reset role;
|
|
163
|
+
|
|
164
|
+
-- The app's hooks, reading a membership table of the test's own --------------------
|
|
165
|
+
create table private.genesis_test_members (tenant uuid, person uuid, admin boolean);
|
|
166
|
+
insert into private.genesis_test_members values
|
|
167
|
+
(pg_temp.tenant(1), pg_temp.person('a1'), true),
|
|
168
|
+
(pg_temp.tenant(1), pg_temp.person('e5'), true),
|
|
169
|
+
(pg_temp.tenant(1), pg_temp.person('b2'), false),
|
|
170
|
+
(pg_temp.tenant(2), pg_temp.person('c3'), true);
|
|
171
|
+
create function private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean
|
|
172
|
+
language sql stable set search_path = '' as $$
|
|
173
|
+
select exists (select 1 from private.genesis_test_members m where m.tenant = $1 and m.person = $2 and m.admin)
|
|
174
|
+
$$;
|
|
175
|
+
create function private.genesis_tenant_member(tenant uuid, person uuid) returns boolean
|
|
176
|
+
language sql stable set search_path = '' as $$
|
|
177
|
+
select exists (select 1 from private.genesis_test_members m where m.tenant = $1 and m.person = $2)
|
|
178
|
+
$$;
|
|
179
|
+
revoke execute on function private.genesis_tenant_admin(uuid, uuid) from public, anon, authenticated;
|
|
180
|
+
revoke execute on function private.genesis_tenant_member(uuid, uuid) from public, anon, authenticated;
|
|
181
|
+
|
|
182
|
+
-- Who may change a switch ------------------------------------------------------------
|
|
183
|
+
set local role anon;
|
|
184
|
+
select throws_ok(
|
|
185
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'x') $$,
|
|
186
|
+
'42501', null, 'anon cannot call set_tenant_feature'
|
|
187
|
+
);
|
|
188
|
+
reset role;
|
|
189
|
+
|
|
190
|
+
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
|
|
191
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
192
|
+
set local role authenticated;
|
|
193
|
+
select throws_ok(
|
|
194
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'x') $$,
|
|
195
|
+
'42501', 'set_tenant_feature: not signed in', 'a call with nobody signed in is refused'
|
|
196
|
+
);
|
|
197
|
+
reset role;
|
|
198
|
+
|
|
199
|
+
select pg_temp.sign_in('b2');
|
|
200
|
+
set local role authenticated;
|
|
201
|
+
select throws_ok(
|
|
202
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'Members want it off') $$,
|
|
203
|
+
'42501', 'set_tenant_feature: only the tenant''s admins can change its features', 'a member who isn''t an admin is refused'
|
|
204
|
+
);
|
|
205
|
+
reset role;
|
|
206
|
+
|
|
207
|
+
select pg_temp.sign_in('c3');
|
|
208
|
+
set local role authenticated;
|
|
209
|
+
select throws_ok(
|
|
210
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'Not mine') $$,
|
|
211
|
+
'42501', null, 'another tenant''s admin is refused'
|
|
212
|
+
);
|
|
213
|
+
reset role;
|
|
214
|
+
|
|
215
|
+
select pg_temp.sign_in('a1');
|
|
216
|
+
set local role authenticated;
|
|
217
|
+
select throws_ok(
|
|
218
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, ' ') $$,
|
|
219
|
+
'22023', null, 'a blank reason is refused'
|
|
220
|
+
);
|
|
221
|
+
select throws_ok(
|
|
222
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, repeat('x', 501)) $$,
|
|
223
|
+
'22023', null, 'a reason over 500 characters is refused'
|
|
224
|
+
);
|
|
225
|
+
select throws_ok(
|
|
226
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', null, 'why') $$,
|
|
227
|
+
'22004', null, 'enabled must be given'
|
|
228
|
+
);
|
|
229
|
+
select throws_ok(
|
|
230
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'Not An Id', false, 'why') $$,
|
|
231
|
+
'23514', null, 'a feature id must have a FeatureId''s shape'
|
|
232
|
+
);
|
|
233
|
+
select lives_ok(
|
|
234
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, ' Launching next month ') $$,
|
|
235
|
+
'the tenant''s admin turns a feature off'
|
|
236
|
+
);
|
|
237
|
+
select lives_ok(
|
|
238
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'billing', false, 'Not offered here') $$,
|
|
239
|
+
'... and another'
|
|
240
|
+
);
|
|
241
|
+
select throws_ok(
|
|
242
|
+
$$ select * from public.tenant_features $$,
|
|
243
|
+
'42501', null, 'authenticated cannot read the table directly'
|
|
244
|
+
);
|
|
245
|
+
select throws_ok(
|
|
246
|
+
$$ insert into public.tenant_features (tenant_id, feature_id, enabled, reason) values (pg_temp.tenant(1), 'x', false, 'x') $$,
|
|
247
|
+
'42501', null, '... nor write it'
|
|
248
|
+
);
|
|
249
|
+
select throws_ok(
|
|
250
|
+
$$ select * from public.tenant_feature_changes $$,
|
|
251
|
+
'42501', null, '... nor read the history directly'
|
|
252
|
+
);
|
|
253
|
+
select throws_ok(
|
|
254
|
+
$$ select private.genesis_is_tenant_admin(pg_temp.tenant(1)) $$,
|
|
255
|
+
'42501', null, '... nor call the hook check itself'
|
|
256
|
+
);
|
|
257
|
+
reset role;
|
|
258
|
+
|
|
259
|
+
select is(
|
|
260
|
+
(select reason from public.tenant_features where tenant_id = pg_temp.tenant(1) and feature_id = 'chat'),
|
|
261
|
+
'Launching next month',
|
|
262
|
+
'the reason is kept, trimmed'
|
|
263
|
+
);
|
|
264
|
+
select is(
|
|
265
|
+
(select updated_by from public.tenant_features where tenant_id = pg_temp.tenant(1) and feature_id = 'chat'),
|
|
266
|
+
pg_temp.person('a1'),
|
|
267
|
+
'... with who set it'
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
select pg_temp.sign_in('e5');
|
|
271
|
+
set local role authenticated;
|
|
272
|
+
select lives_ok(
|
|
273
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'Launching next month') $$,
|
|
274
|
+
'another admin saves the same switch and reason'
|
|
275
|
+
);
|
|
276
|
+
reset role;
|
|
277
|
+
select is(
|
|
278
|
+
(select updated_by from public.tenant_features where tenant_id = pg_temp.tenant(1) and feature_id = 'chat'),
|
|
279
|
+
pg_temp.person('a1'),
|
|
280
|
+
'... which changes nothing, not even who set it'
|
|
281
|
+
);
|
|
282
|
+
select is(
|
|
283
|
+
(select count(*)::int from public.tenant_feature_changes where tenant_id = pg_temp.tenant(1) and feature_id = 'chat'),
|
|
284
|
+
1,
|
|
285
|
+
'... and records nothing'
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
-- Who may read ------------------------------------------------------------------------
|
|
289
|
+
select pg_temp.sign_in('b2');
|
|
290
|
+
set local role authenticated;
|
|
291
|
+
select is(
|
|
292
|
+
(select array_agg(f) from public.tenant_features_off(pg_temp.tenant(1)) f),
|
|
293
|
+
array['billing', 'chat'],
|
|
294
|
+
'a member reads the ids that are off, in order'
|
|
295
|
+
);
|
|
296
|
+
select throws_ok(
|
|
297
|
+
$$ select * from public.tenant_feature_settings(pg_temp.tenant(1)) $$,
|
|
298
|
+
'42501', null, '... but not the reasons (admins only)'
|
|
299
|
+
);
|
|
300
|
+
select throws_ok(
|
|
301
|
+
$$ select * from public.tenant_feature_history(pg_temp.tenant(1)) $$,
|
|
302
|
+
'42501', null, '... nor the history'
|
|
303
|
+
);
|
|
304
|
+
reset role;
|
|
305
|
+
|
|
306
|
+
select pg_temp.sign_in('c3');
|
|
307
|
+
set local role authenticated;
|
|
308
|
+
select throws_ok(
|
|
309
|
+
$$ select * from public.tenant_features_off(pg_temp.tenant(1)) $$,
|
|
310
|
+
'42501', null, 'someone outside the tenant cannot read its switches'
|
|
311
|
+
);
|
|
312
|
+
select is(
|
|
313
|
+
(select count(*)::int from public.tenant_features_off(pg_temp.tenant(2))),
|
|
314
|
+
0,
|
|
315
|
+
'a tenant with no switches has nothing off'
|
|
316
|
+
);
|
|
317
|
+
reset role;
|
|
318
|
+
|
|
319
|
+
select pg_temp.sign_in('a1');
|
|
320
|
+
set local role authenticated;
|
|
321
|
+
select is(
|
|
322
|
+
(select array_agg(feature_id || ':' || enabled || ':' || reason order by feature_id) from public.tenant_feature_settings(pg_temp.tenant(1))),
|
|
323
|
+
array['billing:false:Not offered here', 'chat:false:Launching next month'],
|
|
324
|
+
'the admin reads every switch with its reason'
|
|
325
|
+
);
|
|
326
|
+
select lives_ok(
|
|
327
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', true, 'Launched') $$,
|
|
328
|
+
'the admin turns it back on'
|
|
329
|
+
);
|
|
330
|
+
select is(
|
|
331
|
+
(select array_agg(f) from public.tenant_features_off(pg_temp.tenant(1)) f),
|
|
332
|
+
array['billing'],
|
|
333
|
+
'... and it is no longer off'
|
|
334
|
+
);
|
|
335
|
+
select is(
|
|
336
|
+
(select array_agg(enabled::text || ':' || reason || ':' || changed_role) from public.tenant_feature_history(pg_temp.tenant(1)) where feature_id = 'chat'),
|
|
337
|
+
array['true:Launched:authenticated', 'false:Launching next month:authenticated'],
|
|
338
|
+
'the history keeps both changes, newest first, with the role'
|
|
339
|
+
);
|
|
340
|
+
select is(
|
|
341
|
+
(select count(*)::int from public.tenant_feature_history(pg_temp.tenant(1), 1)),
|
|
342
|
+
1,
|
|
343
|
+
'max_rows limits the history'
|
|
344
|
+
);
|
|
345
|
+
select ok(
|
|
346
|
+
(select bool_and(changed_by = pg_temp.person('a1')) from public.tenant_feature_history(pg_temp.tenant(1))),
|
|
347
|
+
'every change records who made it'
|
|
348
|
+
);
|
|
349
|
+
reset role;
|
|
350
|
+
|
|
351
|
+
-- The SQL gate --------------------------------------------------------------------------
|
|
352
|
+
select ok(not private.tenant_feature_on(pg_temp.tenant(1), 'billing'), 'tenant_feature_on: off when turned off');
|
|
353
|
+
select ok(private.tenant_feature_on(pg_temp.tenant(1), 'chat'), '... on when turned back on');
|
|
354
|
+
select ok(private.tenant_feature_on(pg_temp.tenant(2), 'billing'), '... on with no row (another tenant''s switch doesn''t leak)');
|
|
355
|
+
|
|
356
|
+
-- The service role -------------------------------------------------------------------------
|
|
357
|
+
select set_config('request.jwt.claims', '{"role":"service_role"}', true);
|
|
358
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
359
|
+
set local role service_role;
|
|
360
|
+
select lives_ok(
|
|
361
|
+
$$ insert into public.tenant_features (tenant_id, feature_id, enabled, reason) values (pg_temp.tenant(2), 'chat', false, 'Platform: paused for review') $$,
|
|
362
|
+
'the service role writes a switch directly'
|
|
363
|
+
);
|
|
364
|
+
select throws_ok(
|
|
365
|
+
$$ insert into public.tenant_feature_changes (tenant_id, feature_id, enabled, reason, changed_role) values (pg_temp.tenant(2), 'chat', true, 'forged', 'authenticated') $$,
|
|
366
|
+
'42501', null, '... but cannot forge history'
|
|
367
|
+
);
|
|
368
|
+
reset role;
|
|
369
|
+
select is(
|
|
370
|
+
(select changed_role || ':' || coalesce(changed_by::text, 'nobody') from public.tenant_feature_changes where tenant_id = pg_temp.tenant(2)),
|
|
371
|
+
'service_role:nobody',
|
|
372
|
+
'its write is recorded, with no person'
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
-- A hook that answers null, or fails ----------------------------------------------------------
|
|
376
|
+
create or replace function private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean
|
|
377
|
+
language sql stable set search_path = '' as $$ select null::boolean $$;
|
|
378
|
+
select pg_temp.sign_in('a1');
|
|
379
|
+
set local role authenticated;
|
|
380
|
+
select throws_ok(
|
|
381
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'x') $$,
|
|
382
|
+
'42501', null, 'a null answer from the hook is a no'
|
|
383
|
+
);
|
|
384
|
+
reset role;
|
|
385
|
+
create or replace function private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean
|
|
386
|
+
language plpgsql stable set search_path = '' as $$ begin raise exception 'membership lookup failed'; end $$;
|
|
387
|
+
set local role authenticated;
|
|
388
|
+
select throws_ok(
|
|
389
|
+
$$ select public.set_tenant_feature(pg_temp.tenant(1), 'chat', false, 'x') $$,
|
|
390
|
+
'P0001', 'membership lookup failed', 'a hook that fails refuses the change'
|
|
391
|
+
);
|
|
392
|
+
reset role;
|
|
393
|
+
|
|
394
|
+
-- Deleting a tenant, or a person whose tenant goes with them, never fails on these tables ----------
|
|
395
|
+
select lives_ok($$ delete from public.genesis_test_teams where id = pg_temp.tenant(2) $$, 'deleting a tenant with switches and history succeeds');
|
|
396
|
+
select ok(
|
|
397
|
+
not exists (select 1 from public.tenant_features where tenant_id = pg_temp.tenant(2))
|
|
398
|
+
and not exists (select 1 from public.tenant_feature_changes where tenant_id = pg_temp.tenant(2)),
|
|
399
|
+
'... and takes them with it'
|
|
400
|
+
);
|
|
401
|
+
set local role service_role;
|
|
402
|
+
insert into public.tenant_features (tenant_id, feature_id, enabled, reason) values (pg_temp.tenant(3), 'chat', false, 'Platform: paused');
|
|
403
|
+
reset role;
|
|
404
|
+
-- As delete_my_account does it: the person's own JWT, then the delete.
|
|
405
|
+
select pg_temp.sign_in('d4');
|
|
406
|
+
select lives_ok($$ delete from auth.users where id = pg_temp.person('d4') $$, 'a person whose tenant (with a switch) goes with them can delete their account');
|
|
407
|
+
select ok(not exists (select 1 from public.tenant_features where tenant_id = pg_temp.tenant(3)), '... and the tenant''s switches go too');
|
|
408
|
+
|
|
409
|
+
-- Deleting a person never fails on these tables -------------------------------------------------
|
|
410
|
+
select lives_ok($$ delete from auth.users where id = pg_temp.person('a1') $$, 'deleting the admin who made changes succeeds');
|
|
411
|
+
select ok(
|
|
412
|
+
(select bool_and(changed_by is null) from public.tenant_feature_changes where tenant_id = pg_temp.tenant(1))
|
|
413
|
+
and (select bool_and(updated_by is null) from public.tenant_features where tenant_id = pg_temp.tenant(1)),
|
|
414
|
+
'... and their changes stay, without the person'
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
select * from finish();
|
|
418
|
+
rollback;
|