@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.
@@ -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();