@marktiderman/genesis-supabase 0.3.1 → 0.5.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 +217 -33
- package/dist/blocks.d.ts +8 -1
- package/dist/blocks.js +1 -1
- package/dist/{chunk-T2HGFC4C.js → chunk-A3H5CTYT.js} +11 -3
- package/dist/chunk-A3H5CTYT.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +185 -0
- package/dist/jobs.js +163 -0
- package/dist/jobs.js.map +1 -0
- package/migrations/0007_genesis_jobs.sql +352 -0
- package/migrations/tests/0007_genesis_jobs.test.sql +129 -0
- package/package.json +39 -3
- package/sql/testing/genesis_testing.sql +526 -0
- package/sql/testing/tests/genesis_testing.test.sql +181 -0
- package/dist/chunk-T2HGFC4C.js.map +0 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
-- genesis_testing: pgTAP helpers that check an app's own schema keeps the data contract.
|
|
2
|
+
--
|
|
3
|
+
-- TEST DATABASES ONLY. Never put this file in `supabase/migrations/` (the doctor's supabase pack
|
|
4
|
+
-- flags it there): it signs in as anyone and resets roles, which only a test may do.
|
|
5
|
+
--
|
|
6
|
+
-- Copy this file into your app's `supabase/tests/database/` as `000_genesis_testing.test.sql`. The
|
|
7
|
+
-- leading zeros make `supabase test db` run it first, and it commits the `genesis_testing` schema
|
|
8
|
+
-- (and nothing else) so the test files after it can call the helpers. It ends with a one-line plan
|
|
9
|
+
-- of its own, so pg_prove reports it as a passing test file. Re-running it is safe: everything is
|
|
10
|
+
-- `create or replace`.
|
|
11
|
+
--
|
|
12
|
+
-- Every check runs its statement in a savepoint that is always rolled back, so one check never
|
|
13
|
+
-- changes what the next one sees. Each returns one pgTAP line (`ok …` / `not ok …`), with a `diag`
|
|
14
|
+
-- explaining a failure. The `genesis_testing.probe_*` functions return the same verdict as jsonb
|
|
15
|
+
-- ({ "pass": bool, "detail": text }) for tests of your own helpers.
|
|
16
|
+
--
|
|
17
|
+
-- A check only passes on evidence. A refusal counts only when it is one (42501, or a guard's
|
|
18
|
+
-- `raise exception`, P0001); any other error fails the check, so a typo can't pass. "Changed no
|
|
19
|
+
-- row" and "value unchanged" count only when a control run (the system, or a user you name) can
|
|
20
|
+
-- make the same change, so a missing row or a wrong key can't pass either.
|
|
21
|
+
--
|
|
22
|
+
-- `user` arguments: a uuid acts as that signed-in user; null means the system: no signed-in user and
|
|
23
|
+
-- the test's own role, the way jobs, backfills and migrations run.
|
|
24
|
+
--
|
|
25
|
+
-- Roles and sign-in follow Supabase: `authenticated`/`anon`/`service_role`, and `auth.uid()` reads
|
|
26
|
+
-- `request.jwt.claims` (the legacy `request.jwt.claim.sub` is set too, for older images).
|
|
27
|
+
--
|
|
28
|
+
-- The contract it checks (Genesis data rules):
|
|
29
|
+
-- * refuses: a write the user may not make is refused, or changes no row the control can.
|
|
30
|
+
-- * server_owned: a client can't change columns only the server sets (status, kinds, totals…).
|
|
31
|
+
-- * allows / allows_with_setting / allows_system_paths:
|
|
32
|
+
-- guards let system work through: no signed-in user (jobs, backfills), the
|
|
33
|
+
-- app's bypass settings (e.g. `bt.hard_delete`), cascades and ON DELETE SET NULL.
|
|
34
|
+
-- * never_shows: a private value never appears in what a user can read, reports included.
|
|
35
|
+
-- * sees: a user reads exactly the rows they should.
|
|
36
|
+
--
|
|
37
|
+
-- Requires pgTAP (`create extension if not exists pgtap with schema extensions;`, or any schema).
|
|
38
|
+
|
|
39
|
+
create schema if not exists genesis_testing;
|
|
40
|
+
|
|
41
|
+
-- Fixture people ----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The shared fixture ids, the same ones `@marktiderman/genesis-core/testing` uses:
|
|
45
|
+
* owner, staff, client, stranger.
|
|
46
|
+
*/
|
|
47
|
+
create or replace function genesis_testing.user_id(_who text)
|
|
48
|
+
returns uuid language sql immutable set search_path = '' as $$
|
|
49
|
+
select case _who
|
|
50
|
+
when 'owner' then '00000000-0000-4000-8000-000000000001'::uuid
|
|
51
|
+
when 'staff' then '00000000-0000-4000-8000-000000000002'::uuid
|
|
52
|
+
when 'client' then '00000000-0000-4000-8000-000000000003'::uuid
|
|
53
|
+
when 'stranger' then '00000000-0000-4000-8000-000000000004'::uuid
|
|
54
|
+
end
|
|
55
|
+
$$;
|
|
56
|
+
|
|
57
|
+
/** Creates the four fixture logins in auth.users (skips any that already exist). */
|
|
58
|
+
create or replace function genesis_testing.create_users()
|
|
59
|
+
returns void language sql set search_path = '' as $$
|
|
60
|
+
insert into auth.users (id, instance_id, aud, role, email, raw_app_meta_data, raw_user_meta_data, created_at, updated_at)
|
|
61
|
+
select genesis_testing.user_id(w), '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
|
|
62
|
+
'genesis-testing-' || w || '@example.test', '{}'::jsonb, jsonb_build_object('full_name', initcap(w)), now(), now()
|
|
63
|
+
from unnest(array['owner', 'staff', 'client', 'stranger']) w
|
|
64
|
+
on conflict (id) do nothing
|
|
65
|
+
$$;
|
|
66
|
+
|
|
67
|
+
-- Acting as someone ---------------------------------------------------------------------------
|
|
68
|
+
-- The probes never call a genesis_testing function while acting as a user (they inline
|
|
69
|
+
-- set_config and `reset role`), so the API roles only need sign_out and user_id (see the end).
|
|
70
|
+
|
|
71
|
+
/** Acts as a signed-in user for the rest of the transaction (or the enclosing savepoint). */
|
|
72
|
+
create or replace function genesis_testing.sign_in(_user uuid)
|
|
73
|
+
returns void language plpgsql set search_path = '' as $$
|
|
74
|
+
begin
|
|
75
|
+
perform pg_catalog.set_config('request.jwt.claims',
|
|
76
|
+
pg_catalog.json_build_object('sub', _user, 'role', 'authenticated')::text, true);
|
|
77
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', _user::text, true);
|
|
78
|
+
perform pg_catalog.set_config('role', 'authenticated', true);
|
|
79
|
+
end $$;
|
|
80
|
+
|
|
81
|
+
/** Acts as a fixture person: 'owner', 'staff', 'client' or 'stranger'. */
|
|
82
|
+
create or replace function genesis_testing.sign_in(_who text)
|
|
83
|
+
returns void language sql set search_path = '' as $$
|
|
84
|
+
select genesis_testing.sign_in(genesis_testing.user_id(_who))
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
/** Acts as someone signed out (the anon key). */
|
|
88
|
+
create or replace function genesis_testing.sign_in_anon()
|
|
89
|
+
returns void language plpgsql set search_path = '' as $$
|
|
90
|
+
begin
|
|
91
|
+
perform pg_catalog.set_config('request.jwt.claims', '{"role":"anon"}', true);
|
|
92
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', '', true);
|
|
93
|
+
perform pg_catalog.set_config('role', 'anon', true);
|
|
94
|
+
end $$;
|
|
95
|
+
|
|
96
|
+
/** Back to the test's own role with no claims: the system, as jobs, backfills and migrations run. */
|
|
97
|
+
create or replace function genesis_testing.sign_out()
|
|
98
|
+
returns void language plpgsql set search_path = '' as $$
|
|
99
|
+
begin
|
|
100
|
+
perform pg_catalog.set_config('request.jwt.claims', '', true);
|
|
101
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', '', true);
|
|
102
|
+
execute 'reset role';
|
|
103
|
+
end $$;
|
|
104
|
+
|
|
105
|
+
/** Internal: act as `_user` (null: the system) without calling another genesis_testing function. */
|
|
106
|
+
create or replace function genesis_testing._act(_user uuid)
|
|
107
|
+
returns void language plpgsql set search_path = '' as $$
|
|
108
|
+
begin
|
|
109
|
+
if _user is null then
|
|
110
|
+
perform pg_catalog.set_config('request.jwt.claims', '', true);
|
|
111
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', '', true);
|
|
112
|
+
execute 'reset role';
|
|
113
|
+
else
|
|
114
|
+
perform pg_catalog.set_config('request.jwt.claims',
|
|
115
|
+
pg_catalog.json_build_object('sub', _user, 'role', 'authenticated')::text, true);
|
|
116
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', _user::text, true);
|
|
117
|
+
perform pg_catalog.set_config('role', 'authenticated', true);
|
|
118
|
+
end if;
|
|
119
|
+
end $$;
|
|
120
|
+
|
|
121
|
+
/** Whether an error is a refusal: 42501 (insufficient privilege) or P0001 (a guard's raise). */
|
|
122
|
+
create or replace function genesis_testing.is_refusal(_sqlstate text)
|
|
123
|
+
returns boolean language sql immutable set search_path = '' as $$
|
|
124
|
+
select _sqlstate in ('42501', 'P0001')
|
|
125
|
+
$$;
|
|
126
|
+
|
|
127
|
+
-- Probes (verdicts as jsonb) ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs `_sql` as `_user` (null: the system) in a savepoint that is rolled back. Returns
|
|
131
|
+
* { ok, rows, sqlstate, message }: `ok` is false when it raised.
|
|
132
|
+
*/
|
|
133
|
+
create or replace function genesis_testing.run_as(_sql text, _user uuid)
|
|
134
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
135
|
+
declare
|
|
136
|
+
_rows bigint := 0;
|
|
137
|
+
_out jsonb;
|
|
138
|
+
begin
|
|
139
|
+
begin
|
|
140
|
+
perform genesis_testing._act(_user);
|
|
141
|
+
execute _sql;
|
|
142
|
+
get diagnostics _rows = row_count;
|
|
143
|
+
_out := pg_catalog.jsonb_build_object('ok', true, 'rows', _rows);
|
|
144
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
145
|
+
exception
|
|
146
|
+
when sqlstate 'GT000' then null;
|
|
147
|
+
when others then
|
|
148
|
+
_out := pg_catalog.jsonb_build_object('ok', false, 'rows', 0, 'sqlstate', sqlstate, 'message', sqlerrm);
|
|
149
|
+
end;
|
|
150
|
+
return _out;
|
|
151
|
+
end $$;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `refuses` as a verdict. Passes when `_user` gets a refusal (42501/P0001), or changes no row while
|
|
155
|
+
* the control (`_control_user`, null: the system) changes at least one with the same statement.
|
|
156
|
+
*/
|
|
157
|
+
create or replace function genesis_testing.probe_refuses(_sql text, _user uuid, _control_user uuid default null)
|
|
158
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
159
|
+
declare
|
|
160
|
+
_r jsonb := genesis_testing.run_as(_sql, _user);
|
|
161
|
+
_c jsonb;
|
|
162
|
+
begin
|
|
163
|
+
if not (_r ->> 'ok')::boolean then
|
|
164
|
+
return pg_catalog.jsonb_build_object('pass', genesis_testing.is_refusal(_r ->> 'sqlstate'),
|
|
165
|
+
'detail', case when genesis_testing.is_refusal(_r ->> 'sqlstate') then 'refused: ' || (_r ->> 'message')
|
|
166
|
+
else pg_catalog.format('raised %s (%s), which isn''t a refusal (42501 or P0001)', _r ->> 'sqlstate', _r ->> 'message') end);
|
|
167
|
+
end if;
|
|
168
|
+
if (_r ->> 'rows')::bigint > 0 then
|
|
169
|
+
return pg_catalog.jsonb_build_object('pass', false,
|
|
170
|
+
'detail', pg_catalog.format('it ran and changed %s row(s)', _r ->> 'rows'));
|
|
171
|
+
end if;
|
|
172
|
+
_c := genesis_testing.run_as(_sql, _control_user);
|
|
173
|
+
return pg_catalog.jsonb_build_object('pass', (_c ->> 'ok')::boolean and (_c ->> 'rows')::bigint > 0,
|
|
174
|
+
'detail', case
|
|
175
|
+
when not (_c ->> 'ok')::boolean then pg_catalog.format('the control raised %s (%s), so there was nothing to refuse', _c ->> 'sqlstate', _c ->> 'message')
|
|
176
|
+
when (_c ->> 'rows')::bigint = 0 then 'the control changed no row either: nothing to refuse (check the statement and the row)'
|
|
177
|
+
else 'changed no row; the control changed ' || (_c ->> 'rows') end);
|
|
178
|
+
end $$;
|
|
179
|
+
|
|
180
|
+
/** `allows` as a verdict: ran without an error (optionally changing at least `_min_rows`). */
|
|
181
|
+
create or replace function genesis_testing.probe_allows(_sql text, _user uuid, _min_rows bigint default 0)
|
|
182
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
183
|
+
declare
|
|
184
|
+
_r jsonb := genesis_testing.run_as(_sql, _user);
|
|
185
|
+
begin
|
|
186
|
+
if not (_r ->> 'ok')::boolean then
|
|
187
|
+
return pg_catalog.jsonb_build_object('pass', false,
|
|
188
|
+
'detail', pg_catalog.format('raised %s: %s', _r ->> 'sqlstate', _r ->> 'message'));
|
|
189
|
+
end if;
|
|
190
|
+
return pg_catalog.jsonb_build_object('pass', (_r ->> 'rows')::bigint >= _min_rows,
|
|
191
|
+
'detail', pg_catalog.format('changed %s row(s), expected at least %s', _r ->> 'rows', _min_rows));
|
|
192
|
+
end $$;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* An SQL expression of `_type` that differs from `_current` (its text form), or null when the type
|
|
196
|
+
* needs a value from the caller (arrays, ranges, composite and other types). Domains use their base
|
|
197
|
+
* type; enums pick another label.
|
|
198
|
+
*/
|
|
199
|
+
create or replace function genesis_testing.other_value(_type regtype, _current text)
|
|
200
|
+
returns text language plpgsql stable set search_path = '' as $$
|
|
201
|
+
declare
|
|
202
|
+
_base regtype := _type;
|
|
203
|
+
_label text;
|
|
204
|
+
begin
|
|
205
|
+
select coalesce(nullif(t.typbasetype, 0), t.oid)::regtype into _base from pg_catalog.pg_type t where t.oid = _type;
|
|
206
|
+
if exists (select 1 from pg_catalog.pg_type t where t.oid = _base and t.typtype = 'e') then
|
|
207
|
+
select e.enumlabel into _label from pg_catalog.pg_enum e
|
|
208
|
+
where e.enumtypid = _base and e.enumlabel is distinct from _current
|
|
209
|
+
order by e.enumsortorder limit 1;
|
|
210
|
+
return case when _label is null then null else pg_catalog.quote_literal(_label) || '::' || _type::text end;
|
|
211
|
+
end if;
|
|
212
|
+
return case
|
|
213
|
+
when _base = 'boolean'::regtype then pg_catalog.quote_literal(coalesce(not _current::boolean, true)::text)
|
|
214
|
+
when _base in ('smallint'::regtype, 'integer'::regtype, 'bigint'::regtype, 'numeric'::regtype,
|
|
215
|
+
'real'::regtype, 'double precision'::regtype)
|
|
216
|
+
then pg_catalog.quote_literal((coalesce(_current::numeric, 0) + 1)::text)
|
|
217
|
+
when _base = 'uuid'::regtype then pg_catalog.quote_literal(gen_random_uuid()::text)
|
|
218
|
+
when _base in ('timestamptz'::regtype, 'timestamp'::regtype)
|
|
219
|
+
then pg_catalog.quote_literal((coalesce(_current::timestamptz, now()) + interval '1 day')::text)
|
|
220
|
+
when _base = 'date'::regtype then pg_catalog.quote_literal((coalesce(_current::date, current_date) + 1)::text)
|
|
221
|
+
when _base in ('jsonb'::regtype, 'json'::regtype) then pg_catalog.quote_literal('{"genesis_testing":true}')
|
|
222
|
+
when (select t.typname from pg_catalog.pg_type t where t.oid = _base) in ('text', 'varchar', 'bpchar', 'citext')
|
|
223
|
+
then pg_catalog.quote_literal('genesis_testing:' || coalesce(_current, ''))
|
|
224
|
+
else null
|
|
225
|
+
end || '::' || _type::text;
|
|
226
|
+
end $$;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `server_owned` as a verdict for one column. A control run (the system) first sets `_column` of
|
|
230
|
+
* the row `_key_column` = `_key` to a different value (`_value`, an SQL expression, or one chosen
|
|
231
|
+
* for the type) and must change the stored value: that proves the row exists and the value is
|
|
232
|
+
* writable at all. Then, as `_user`, the same update must leave the stored value as it was, whether
|
|
233
|
+
* it was refused (42501/P0001), matched no row, or a trigger put the value back. Any other error
|
|
234
|
+
* fails the check.
|
|
235
|
+
*/
|
|
236
|
+
create or replace function genesis_testing.probe_server_owned(
|
|
237
|
+
_table regclass, _key_column text, _key text, _column text, _user uuid, _value text default null)
|
|
238
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
239
|
+
declare
|
|
240
|
+
_type regtype;
|
|
241
|
+
_expr text;
|
|
242
|
+
_update text;
|
|
243
|
+
_read text;
|
|
244
|
+
_before text;
|
|
245
|
+
_control text;
|
|
246
|
+
_after text;
|
|
247
|
+
_state text;
|
|
248
|
+
_err text;
|
|
249
|
+
begin
|
|
250
|
+
select a.atttypid::regtype into _type
|
|
251
|
+
from pg_catalog.pg_attribute a
|
|
252
|
+
where a.attrelid = _table and a.attname = _column and a.attnum > 0 and not a.attisdropped;
|
|
253
|
+
if _type is null then
|
|
254
|
+
return pg_catalog.jsonb_build_object('pass', false, 'detail', pg_catalog.format('%s has no column %s', _table, _column));
|
|
255
|
+
end if;
|
|
256
|
+
_read := pg_catalog.format('select (%I)::text from %s where %I::text = %L', _column, _table, _key_column, _key);
|
|
257
|
+
execute _read into _before;
|
|
258
|
+
_expr := coalesce(_value, genesis_testing.other_value(_type, _before));
|
|
259
|
+
if _expr is null then
|
|
260
|
+
return pg_catalog.jsonb_build_object('pass', false,
|
|
261
|
+
'detail', pg_catalog.format('pass a value for %s (type %s) to try', _column, _type));
|
|
262
|
+
end if;
|
|
263
|
+
_update := pg_catalog.format('update %s set %I = %s where %I::text = %L', _table, _column, _expr, _key_column, _key);
|
|
264
|
+
|
|
265
|
+
-- Control: the system can make the change (the row exists and the new value differs).
|
|
266
|
+
begin
|
|
267
|
+
perform genesis_testing._act(null);
|
|
268
|
+
begin
|
|
269
|
+
execute _update;
|
|
270
|
+
execute _read into _control;
|
|
271
|
+
exception when others then
|
|
272
|
+
_err := sqlstate || ' ' || sqlerrm;
|
|
273
|
+
end;
|
|
274
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
275
|
+
exception when sqlstate 'GT000' then null;
|
|
276
|
+
end;
|
|
277
|
+
if _err is not null then
|
|
278
|
+
return pg_catalog.jsonb_build_object('pass', false, 'detail', 'the control update raised ' || _err);
|
|
279
|
+
end if;
|
|
280
|
+
if _control is not distinct from _before then
|
|
281
|
+
return pg_catalog.jsonb_build_object('pass', false, 'detail', pg_catalog.format(
|
|
282
|
+
'the control couldn''t change %s either (no row with %s = %s, or the same value): nothing was tested',
|
|
283
|
+
_column, _key_column, _key));
|
|
284
|
+
end if;
|
|
285
|
+
|
|
286
|
+
-- As the user.
|
|
287
|
+
begin
|
|
288
|
+
perform genesis_testing._act(_user);
|
|
289
|
+
begin
|
|
290
|
+
execute _update;
|
|
291
|
+
exception when others then
|
|
292
|
+
_state := sqlstate;
|
|
293
|
+
_err := sqlstate || ' ' || sqlerrm;
|
|
294
|
+
end;
|
|
295
|
+
-- Back to the test's role inline: the API roles can't call genesis_testing._act.
|
|
296
|
+
execute 'reset role';
|
|
297
|
+
execute _read into _after;
|
|
298
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
299
|
+
exception when sqlstate 'GT000' then null;
|
|
300
|
+
end;
|
|
301
|
+
if _state is not null and not genesis_testing.is_refusal(_state) then
|
|
302
|
+
return pg_catalog.jsonb_build_object('pass', false,
|
|
303
|
+
'detail', 'raised ' || _err || ', which isn''t a refusal (42501 or P0001)');
|
|
304
|
+
end if;
|
|
305
|
+
return pg_catalog.jsonb_build_object('pass', _after is not distinct from _before,
|
|
306
|
+
'detail', case
|
|
307
|
+
when _after is not distinct from _before then coalesce('refused: ' || _err, 'unchanged')
|
|
308
|
+
else pg_catalog.format('%s changed from %s to %s', _column, coalesce(_before, 'null'), coalesce(_after, 'null'))
|
|
309
|
+
end);
|
|
310
|
+
end $$;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `never_shows` as a verdict: runs `_query` as `_user` (null: the system; `_anon`: signed out with
|
|
314
|
+
* the anon key) and passes when `_marker` appears nowhere in its rows. A refusal (42501/P0001)
|
|
315
|
+
* counts as showing nothing; any other error fails the check.
|
|
316
|
+
*/
|
|
317
|
+
create or replace function genesis_testing.probe_never_shows(_query text, _marker text, _user uuid, _anon boolean default false)
|
|
318
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
319
|
+
declare
|
|
320
|
+
_text text;
|
|
321
|
+
_esc text;
|
|
322
|
+
_shown boolean;
|
|
323
|
+
_state text;
|
|
324
|
+
_err text;
|
|
325
|
+
begin
|
|
326
|
+
begin
|
|
327
|
+
if _anon then
|
|
328
|
+
perform pg_catalog.set_config('request.jwt.claims', '{"role":"anon"}', true);
|
|
329
|
+
perform pg_catalog.set_config('request.jwt.claim.sub', '', true);
|
|
330
|
+
perform pg_catalog.set_config('role', 'anon', true);
|
|
331
|
+
else
|
|
332
|
+
perform genesis_testing._act(_user);
|
|
333
|
+
end if;
|
|
334
|
+
begin
|
|
335
|
+
execute pg_catalog.format('select coalesce(jsonb_agg(to_jsonb(q))::text, '''') from (%s) q', _query) into _text;
|
|
336
|
+
exception when others then
|
|
337
|
+
_state := sqlstate;
|
|
338
|
+
_err := sqlstate || ' ' || sqlerrm;
|
|
339
|
+
end;
|
|
340
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
341
|
+
exception when sqlstate 'GT000' then null;
|
|
342
|
+
end;
|
|
343
|
+
if _state is not null then
|
|
344
|
+
return pg_catalog.jsonb_build_object('pass', genesis_testing.is_refusal(_state),
|
|
345
|
+
'detail', case when genesis_testing.is_refusal(_state) then 'refused: ' || _err
|
|
346
|
+
else 'raised ' || _err || ', which isn''t a refusal: fix the query' end);
|
|
347
|
+
end if;
|
|
348
|
+
-- The rows are serialized as JSON, which escapes quotes, backslashes and control characters,
|
|
349
|
+
-- so look for the marker as JSON writes it too.
|
|
350
|
+
_esc := pg_catalog.to_jsonb(_marker)::text;
|
|
351
|
+
_esc := pg_catalog.substr(_esc, 2, pg_catalog.length(_esc) - 2);
|
|
352
|
+
_shown := pg_catalog.strpos(_text, _marker) > 0 or pg_catalog.strpos(_text, _esc) > 0;
|
|
353
|
+
return pg_catalog.jsonb_build_object('pass', not _shown,
|
|
354
|
+
'detail', case when not _shown then 'not shown'
|
|
355
|
+
else pg_catalog.format('%L appears in: %s', _marker, pg_catalog.left(_text, 300)) end);
|
|
356
|
+
end $$;
|
|
357
|
+
|
|
358
|
+
/** `sees` as a verdict: `_query` returns exactly `_expected` rows for `_user` (null: the system). */
|
|
359
|
+
create or replace function genesis_testing.probe_sees(_query text, _user uuid, _expected bigint)
|
|
360
|
+
returns jsonb language plpgsql set search_path = '' as $$
|
|
361
|
+
declare
|
|
362
|
+
_n bigint;
|
|
363
|
+
_err text;
|
|
364
|
+
begin
|
|
365
|
+
begin
|
|
366
|
+
perform genesis_testing._act(_user);
|
|
367
|
+
begin
|
|
368
|
+
execute pg_catalog.format('select count(*) from (%s) q', _query) into _n;
|
|
369
|
+
exception when others then
|
|
370
|
+
_err := sqlstate || ' ' || sqlerrm;
|
|
371
|
+
end;
|
|
372
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
373
|
+
exception when sqlstate 'GT000' then null;
|
|
374
|
+
end;
|
|
375
|
+
if _err is not null then
|
|
376
|
+
return pg_catalog.jsonb_build_object('pass', false, 'detail', 'raised ' || _err);
|
|
377
|
+
end if;
|
|
378
|
+
return pg_catalog.jsonb_build_object('pass', _n = _expected,
|
|
379
|
+
'detail', pg_catalog.format('saw %s row(s), expected %s', _n, _expected));
|
|
380
|
+
end $$;
|
|
381
|
+
|
|
382
|
+
-- Assertions (pgTAP lines) --------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* One pgTAP line from a verdict, with the detail as a diagnostic when it failed. pgTAP's own
|
|
386
|
+
* functions call each other unqualified, so this finds pgTAP's schema and runs with it on the path.
|
|
387
|
+
*/
|
|
388
|
+
create or replace function genesis_testing.tap(_verdict jsonb, _description text)
|
|
389
|
+
returns text language plpgsql set search_path = '' as $$
|
|
390
|
+
declare
|
|
391
|
+
_schema text;
|
|
392
|
+
_line text;
|
|
393
|
+
_path text := pg_catalog.current_setting('search_path');
|
|
394
|
+
begin
|
|
395
|
+
select n.nspname into _schema
|
|
396
|
+
from pg_catalog.pg_extension x join pg_catalog.pg_namespace n on n.oid = x.extnamespace
|
|
397
|
+
where x.extname = 'pgtap';
|
|
398
|
+
if _schema is null then
|
|
399
|
+
raise exception 'genesis_testing needs pgTAP: create extension pgtap';
|
|
400
|
+
end if;
|
|
401
|
+
perform pg_catalog.set_config('search_path', pg_catalog.quote_ident(_schema), true);
|
|
402
|
+
execute 'select ok($1, $2)' into _line using (_verdict ->> 'pass')::boolean, _description;
|
|
403
|
+
if not (_verdict ->> 'pass')::boolean then
|
|
404
|
+
execute 'select $1 || E''\n'' || diag($2)' into _line using _line, _verdict ->> 'detail';
|
|
405
|
+
end if;
|
|
406
|
+
perform pg_catalog.set_config('search_path', _path, true);
|
|
407
|
+
return _line;
|
|
408
|
+
end $$;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `_user` may not do `_sql`: it's refused (42501/P0001), or changes no row while the control
|
|
412
|
+
* (`_control_user`, null: the system) changes at least one.
|
|
413
|
+
*/
|
|
414
|
+
create or replace function genesis_testing.refuses(_sql text, _user uuid, _description text, _control_user uuid default null)
|
|
415
|
+
returns text language sql set search_path = '' as $$
|
|
416
|
+
select genesis_testing.tap(genesis_testing.probe_refuses(_sql, _user, _control_user), _description)
|
|
417
|
+
$$;
|
|
418
|
+
|
|
419
|
+
/** `_user` (null: the system) can do `_sql` without an error, changing at least `_min_rows`. */
|
|
420
|
+
create or replace function genesis_testing.allows(_sql text, _user uuid, _description text, _min_rows bigint default 0)
|
|
421
|
+
returns text language sql set search_path = '' as $$
|
|
422
|
+
select genesis_testing.tap(genesis_testing.probe_allows(_sql, _user, _min_rows), _description)
|
|
423
|
+
$$;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* `_sql` runs with the bypass setting `_setting` = `_value` (e.g. 'bt.hard_delete' = 'on'), as
|
|
427
|
+
* `_user` (null: the system). The setting only lasts for this check.
|
|
428
|
+
*/
|
|
429
|
+
create or replace function genesis_testing.allows_with_setting(
|
|
430
|
+
_sql text, _setting text, _value text, _user uuid, _description text)
|
|
431
|
+
returns text language plpgsql set search_path = '' as $$
|
|
432
|
+
declare
|
|
433
|
+
_verdict jsonb;
|
|
434
|
+
begin
|
|
435
|
+
begin
|
|
436
|
+
perform pg_catalog.set_config(_setting, _value, true);
|
|
437
|
+
_verdict := genesis_testing.probe_allows(_sql, _user);
|
|
438
|
+
raise exception using errcode = 'GT000', message = 'genesis_testing rollback';
|
|
439
|
+
exception when sqlstate 'GT000' then null;
|
|
440
|
+
end;
|
|
441
|
+
return genesis_testing.tap(_verdict, _description);
|
|
442
|
+
end $$;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Every system path can do `_sql`: the system (signed out) and each bypass setting in `_settings`
|
|
446
|
+
* ('name=value'), as `_user` when one is given. One line per path.
|
|
447
|
+
*/
|
|
448
|
+
create or replace function genesis_testing.allows_system_paths(
|
|
449
|
+
_sql text, _settings text[], _user uuid, _description text)
|
|
450
|
+
returns setof text language plpgsql set search_path = '' as $$
|
|
451
|
+
declare
|
|
452
|
+
_s text;
|
|
453
|
+
begin
|
|
454
|
+
return next genesis_testing.allows(_sql, null, _description || ' (the system, no signed-in user)');
|
|
455
|
+
foreach _s in array coalesce(_settings, array[]::text[]) loop
|
|
456
|
+
-- Split on the first '=' only: the value may contain '=' itself.
|
|
457
|
+
return next genesis_testing.allows_with_setting(_sql, pg_catalog.split_part(_s, '=', 1),
|
|
458
|
+
pg_catalog.substr(_s, pg_catalog.strpos(_s, '=') + 1), _user,
|
|
459
|
+
pg_catalog.format('%s (%s)', _description, _s));
|
|
460
|
+
end loop;
|
|
461
|
+
end $$;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A client can't change any of `_columns` on the row `_key_column` = `_key`: one line per column.
|
|
465
|
+
* Use it for every column only the server sets (status the server derives, kinds, totals, owner
|
|
466
|
+
* and audit columns, timestamps a trigger stamps). For an array, range or composite column, call
|
|
467
|
+
* `probe_server_owned` with a `_value` to try.
|
|
468
|
+
*/
|
|
469
|
+
create or replace function genesis_testing.server_owned(
|
|
470
|
+
_table regclass, _key_column text, _key text, _columns text[], _user uuid)
|
|
471
|
+
returns setof text language plpgsql set search_path = '' as $$
|
|
472
|
+
declare
|
|
473
|
+
_c text;
|
|
474
|
+
begin
|
|
475
|
+
foreach _c in array _columns loop
|
|
476
|
+
return next genesis_testing.tap(
|
|
477
|
+
genesis_testing.probe_server_owned(_table, _key_column, _key, _c, _user),
|
|
478
|
+
pg_catalog.format('%s.%s is server-owned for %s', _table, _c, coalesce(_user::text, 'the system')));
|
|
479
|
+
end loop;
|
|
480
|
+
end $$;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* `_marker` (a value only a private row holds) never appears in what `_user` reads through
|
|
484
|
+
* `_query`: a table, a view, or a report function (`select * from report_fn(…)`). Use it for
|
|
485
|
+
* every security-definer function that reads a table with a privacy flag.
|
|
486
|
+
*/
|
|
487
|
+
create or replace function genesis_testing.never_shows(_query text, _marker text, _user uuid, _description text)
|
|
488
|
+
returns text language sql set search_path = '' as $$
|
|
489
|
+
select genesis_testing.tap(genesis_testing.probe_never_shows(_query, _marker, _user), _description)
|
|
490
|
+
$$;
|
|
491
|
+
|
|
492
|
+
/** Like never_shows, signed out with the anon key. */
|
|
493
|
+
create or replace function genesis_testing.never_shows_anon(_query text, _marker text, _description text)
|
|
494
|
+
returns text language sql set search_path = '' as $$
|
|
495
|
+
select genesis_testing.tap(genesis_testing.probe_never_shows(_query, _marker, null, true), _description)
|
|
496
|
+
$$;
|
|
497
|
+
|
|
498
|
+
/** `_user` (null: the system) reads exactly `_expected` rows through `_query`. */
|
|
499
|
+
create or replace function genesis_testing.sees(_query text, _user uuid, _expected bigint, _description text)
|
|
500
|
+
returns text language sql set search_path = '' as $$
|
|
501
|
+
select genesis_testing.tap(genesis_testing.probe_sees(_query, _user, _expected), _description)
|
|
502
|
+
$$;
|
|
503
|
+
|
|
504
|
+
-- Privileges: the API roles can't call any of this, except sign_out (a test calls it while acting
|
|
505
|
+
-- as a user to get back to its own role) and user_id (a fixed id, used in assertions made while
|
|
506
|
+
-- signed in). To switch users, sign_out first. Revoked by name (no blanket grants or revokes).
|
|
507
|
+
revoke usage on schema genesis_testing from public;
|
|
508
|
+
do $$
|
|
509
|
+
declare
|
|
510
|
+
_f regprocedure;
|
|
511
|
+
begin
|
|
512
|
+
for _f in
|
|
513
|
+
select p.oid::regprocedure from pg_catalog.pg_proc p
|
|
514
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
515
|
+
where n.nspname = 'genesis_testing'
|
|
516
|
+
loop
|
|
517
|
+
execute pg_catalog.format('revoke execute on function %s from public, anon, authenticated, service_role', _f);
|
|
518
|
+
end loop;
|
|
519
|
+
end $$;
|
|
520
|
+
grant usage on schema genesis_testing to anon, authenticated;
|
|
521
|
+
grant execute on function genesis_testing.sign_out(), genesis_testing.user_id(text) to anon, authenticated;
|
|
522
|
+
|
|
523
|
+
-- When run as a test file of its own (000_genesis_testing.test.sql), report that it loaded.
|
|
524
|
+
select plan(1);
|
|
525
|
+
select has_schema('genesis_testing', 'genesis_testing helpers are loaded');
|
|
526
|
+
select * from finish();
|