@marktiderman/genesis-supabase 0.3.0 → 0.4.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 CHANGED
@@ -4,16 +4,30 @@ Supabase for Genesis apps on web and React Native.
4
4
 
5
5
  ```ts
6
6
  import { createGenesisSupabase, must } from "@marktiderman/genesis-supabase";
7
- import { keychainStorage, refreshWhileActive, wipeSessionOnReinstall } from "@marktiderman/genesis-supabase/native";
7
+ import {
8
+ keychainStorage,
9
+ refreshWhileActive,
10
+ wipeSessionOnReinstall,
11
+ } from "@marktiderman/genesis-supabase/native";
8
12
  import * as SecureStore from "expo-secure-store";
9
13
  import * as ExpoCrypto from "expo-crypto";
10
14
  import AsyncStorage from "@react-native-async-storage/async-storage";
11
15
  import { AppState } from "react-native";
12
16
 
13
17
  const session = keychainStorage(SecureStore);
14
- await wipeSessionOnReinstall({ session, sessionKey: "sb-<project-ref>-auth-token", installMarker: AsyncStorage });
15
-
16
- export const supabase = createGenesisSupabase<Database>({ url, publishableKey, storage: session, platform: "native", crypto: ExpoCrypto });
18
+ await wipeSessionOnReinstall({
19
+ session,
20
+ sessionKey: "sb-<project-ref>-auth-token",
21
+ installMarker: AsyncStorage,
22
+ });
23
+
24
+ export const supabase = createGenesisSupabase<Database>({
25
+ url,
26
+ publishableKey,
27
+ storage: session,
28
+ platform: "native",
29
+ crypto: ExpoCrypto,
30
+ });
17
31
  refreshWhileActive(supabase, AppState);
18
32
 
19
33
  const rows = must(await supabase.from("tasks").select("*"));
@@ -47,18 +61,25 @@ import Constants from "expo-constants";
47
61
  import { storeAppleToken } from "@marktiderman/genesis-supabase";
48
62
  import { signInWithApple } from "@marktiderman/genesis-supabase/native";
49
63
 
50
- const digest = (s: string) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
64
+ const digest = (s: string) =>
65
+ Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
51
66
 
52
67
  async function onApple() {
53
68
  // The nonce is 32 random bytes from crypto.getRandomValues (createGenesisSupabase installs it on native).
54
- const result = await signInWithApple(supabase, AppleAuthentication, { digest });
69
+ const result = await signInWithApple(supabase, AppleAuthentication, {
70
+ digest,
71
+ });
55
72
  if (result.canceled) return; // they closed Apple's sheet: not an error
56
73
  if (result.error) return showError(result.error.message); // result.error.code says why
57
74
  // Apple shares the name on the first sign-in only, so save it now.
58
- if (result.fullName) await supabase.auth.updateUser({ data: { full_name: result.fullName } });
75
+ if (result.fullName)
76
+ await supabase.auth.updateUser({ data: { full_name: result.fullName } });
59
77
  // For revoking on deletion: hand the single-use code to the server now (see "Revoking Apple's tokens").
60
78
  if (result.authorizationCode) {
61
- const stored = await storeAppleToken(supabase, { authorizationCode: result.authorizationCode, clientId: Constants.expoConfig?.ios?.bundleIdentifier });
79
+ const stored = await storeAppleToken(supabase, {
80
+ authorizationCode: result.authorizationCode,
81
+ clientId: Constants.expoConfig?.ios?.bundleIdentifier,
82
+ });
62
83
  if (!stored.ok) console.warn(stored.error.message); // they're signed in either way
63
84
  }
64
85
  }
@@ -84,10 +105,15 @@ async function onApple() {
84
105
  import { GoogleOneTapSignIn } from "@react-native-google-signin/google-signin";
85
106
  import { signInWithGoogle } from "@marktiderman/genesis-supabase/native";
86
107
 
87
- GoogleOneTapSignIn.configure({ webClientId: WEB_CLIENT_ID, iosClientId: IOS_CLIENT_ID }); // once, at start-up
108
+ GoogleOneTapSignIn.configure({
109
+ webClientId: WEB_CLIENT_ID,
110
+ iosClientId: IOS_CLIENT_ID,
111
+ }); // once, at start-up
88
112
 
89
113
  async function onGoogle() {
90
- const result = await signInWithGoogle(supabase, GoogleOneTapSignIn, { digest });
114
+ const result = await signInWithGoogle(supabase, GoogleOneTapSignIn, {
115
+ digest,
116
+ });
91
117
  if (result.canceled) return;
92
118
  if (result.error) return showError(result.error.message);
93
119
  }
@@ -119,11 +145,15 @@ import * as WebBrowser from "expo-web-browser";
119
145
  import { completeOAuthRedirect } from "@marktiderman/genesis-supabase/native";
120
146
 
121
147
  const redirectTo = "myapp://auth/callback";
122
- const { data } = await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo, skipBrowserRedirect: true } });
148
+ const { data } = await supabase.auth.signInWithOAuth({
149
+ provider: "google",
150
+ options: { redirectTo, skipBrowserRedirect: true },
151
+ });
123
152
  const res = await WebBrowser.openAuthSessionAsync(data.url!, redirectTo);
124
153
  if (res.type === "success") {
125
154
  const result = await completeOAuthRedirect(supabase, res.url);
126
- if (result.error) showError(result.error.message); // never result.error.providerMessage
155
+ if (result.error)
156
+ showError(result.error.message); // never result.error.providerMessage
127
157
  else if (result.redirectType === "recovery") router.push("/set-password");
128
158
  }
129
159
  ```
@@ -163,12 +193,12 @@ test is `migrations/tests/0001_genesis_account_deletion.test.sql`: copy it to
163
193
 
164
194
  It refuses, by error code (`SupabaseQueryError.code` on the client):
165
195
 
166
- | Code | When |
167
- |---|---|
196
+ | Code | When |
197
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
168
198
  | `42501` | Nobody is signed in, or the person has a verified MFA factor and the session isn't `aal2`: verify the second factor, then retry. |
169
- | `GA001` | The app's hook refused (below). |
170
- | `GA002` | The app requires a recent sign-in and this one isn't: sign in again, then retry. |
171
- | `23503` | A row still references the login: a foreign key without `on delete cascade`, or files in Storage. |
199
+ | `GA001` | The app's hook refused (below). |
200
+ | `GA002` | The app requires a recent sign-in and this one isn't: sign in again, then retry. |
201
+ | `23503` | A row still references the login: a foreign key without `on delete cascade`, or files in Storage. |
172
202
 
173
203
  - **Your data:** deleting the login removes the rows whose foreign keys to `auth.users(id)` are
174
204
  `on delete cascade`, and is refused (`23503`) while any other row still points at it, so the
@@ -354,6 +384,15 @@ and release notes (`notes`, an array of `{ version, title?, body }`). Copy it in
354
384
 
355
385
  ## Push (the push block)
356
386
 
387
+ > **Superseded by [`@marktiderman/genesis-notifications`](../notifications/README.md).** Its
388
+ > migration adopts this one's three tables in place (adding columns, changing no row), replaces
389
+ > `genesis_push_register` and `genesis_push_forget` with stricter versions (a token moves to
390
+ > another login only from the same phone, and forget reveals nothing), and its runner replaces
391
+ > the `genesis-push-send` function and the sender functions below. Migration 0005 and the
392
+ > function stay here unchanged for apps that already run them; new apps take the notifications
393
+ > block instead. When you move, remove 0005's pgTAP test from your app (two of its assertions
394
+ > describe the old register and forget) and stop calling `genesis-push-send`.
395
+
357
396
  [`migrations/0005_genesis_push.sql`](./migrations/0005_genesis_push.sql) and the
358
397
  [`genesis-push-send`](./functions/genesis-push-send) Edge Function are the server half of
359
398
  `@marktiderman/genesis-native/push`. Copy the migration into your app's `supabase/migrations/`
@@ -467,11 +506,11 @@ into your app under a new timestamp; it stands alone.
467
506
 
468
507
  Functions (templates; copy the folder into `supabase/functions/`):
469
508
 
470
- | Function | What it does | Secrets |
471
- |---|---|---|
472
- | `genesis-support-intake` | The wire contract over HTTP (boot, submit, list) for clients without supabase-js; acts as the caller, never the service role | `GENESIS_SUPPORT_ORIGINS` (web origins) |
473
- | `genesis-support-notify` | A Database Webhook on new reports: a GitHub issue per report and/or a signed webhook, with a summary only (no person, device, context or screenshot) | `GENESIS_SUPPORT_WEBHOOK_SECRET`, `GITHUB_TOKEN` + `GITHUB_REPO`, `SUPPORT_WEBHOOK_URL` (+ `SUPPORT_WEBHOOK_SIGNING_SECRET`) |
474
- | `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API | `GENESIS_SUPPORT_CLEANUP_SECRET` |
509
+ | Function | What it does | Secrets |
510
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
511
+ | `genesis-support-intake` | The wire contract over HTTP (boot, submit, list) for clients without supabase-js; acts as the caller, never the service role | `GENESIS_SUPPORT_ORIGINS` (web origins) |
512
+ | `genesis-support-notify` | A Database Webhook on new reports: a GitHub issue per report and/or a signed webhook, with a summary only (no person, device, context or screenshot) | `GENESIS_SUPPORT_WEBHOOK_SECRET`, `GITHUB_TOKEN` + `GITHUB_REPO`, `SUPPORT_WEBHOOK_URL` (+ `SUPPORT_WEBHOOK_SIGNING_SECRET`) |
513
+ | `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API | `GENESIS_SUPPORT_CLEANUP_SECRET` |
475
514
 
476
515
  All three deploy with `verify_jwt = false` (each checks its caller or secret itself).
477
516
 
@@ -508,22 +547,31 @@ alter table public.tenant_feature_changes add foreign key (tenant_id) references
508
547
  Without the admin hook nobody can change a switch (`42501`, with a hint): it fails closed.
509
548
 
510
549
  ```ts
511
- import { setTenantFeature, tenantFeatures, tenantFeatureHistory } from "@marktiderman/genesis-supabase";
550
+ import {
551
+ setTenantFeature,
552
+ tenantFeatures,
553
+ tenantFeatureHistory,
554
+ } from "@marktiderman/genesis-supabase";
512
555
 
513
556
  const features = await tenantFeatures(supabase, teamId); // members and admins; 42501 for anyone else
514
557
  if (features.isOn("chat")) showChatTab();
515
558
 
516
- await setTenantFeature(supabase, { tenantId: teamId, featureId: "chat", enabled: false, reason: "Launching next month" });
559
+ await setTenantFeature(supabase, {
560
+ tenantId: teamId,
561
+ featureId: "chat",
562
+ enabled: false,
563
+ reason: "Launching next month",
564
+ });
517
565
  const history = await tenantFeatureHistory(supabase, teamId); // admins: who, when, why, newest first
518
566
  ```
519
567
 
520
- | | |
521
- | --- | --- |
568
+ | | |
569
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------ |
522
570
  | `public.set_tenant_feature(tenant, feature, enabled, reason)` | admins only (the hook); a reason of 1 to 500 characters; a FeatureId-shaped id |
523
- | `public.tenant_features_off(tenant)` | the ids that are off; members (`private.genesis_tenant_member`) and admins |
524
- | `public.tenant_feature_settings(tenant)` | every switch with its reason, who and when; admins |
525
- | `public.tenant_feature_history(tenant, max_rows)` | every change, newest first; admins |
526
- | `private.tenant_feature_on(tenant, feature)` | for SQL gates; a feature with no row is on |
571
+ | `public.tenant_features_off(tenant)` | the ids that are off; members (`private.genesis_tenant_member`) and admins |
572
+ | `public.tenant_feature_settings(tenant)` | every switch with its reason, who and when; admins |
573
+ | `public.tenant_feature_history(tenant, max_rows)` | every change, newest first; admins |
574
+ | `private.tenant_feature_on(tenant, feature)` | for SQL gates; a feature with no row is on |
527
575
 
528
576
  Every change, through the function or by the service role writing the table directly, is
529
577
  recorded by a trigger in `public.tenant_feature_changes` with the person (none for the service
@@ -541,6 +589,52 @@ A delete that cascades into `tenant_features` (from your tenant table, or a pers
541
589
  goes with them) isn't recorded, so it never refuses the tenant's or the person's deletion; saving
542
590
  the same switch and reason again changes nothing, not even who and when.
543
591
 
592
+ ## Testing your schema's data contract (genesis_testing)
593
+
594
+ `sql/testing/genesis_testing.sql` is a pgTAP kit that checks your own tables and functions keep the
595
+ Genesis data rules. **Test databases only:** never put it in `supabase/migrations/`. It can sign
596
+ in as anyone, and the API roles can't call it (only `sign_out` and `user_id`). Copy it to `supabase/tests/database/000_genesis_testing.test.sql` (the leading
597
+ zeros make `supabase test db` run it first; it commits only the `genesis_testing` schema and reports
598
+ one passing line of its own). Then call the helpers from your own test files, inside their usual
599
+ `begin; … rollback;`:
600
+
601
+ ```sql
602
+ select genesis_testing.create_users(); -- owner, staff, client, stranger (fixed ids)
603
+ select genesis_testing.refuses($$delete from notes where id = '…'$$,
604
+ genesis_testing.user_id('client'), 'a client can''t delete a note outright');
605
+ select genesis_testing.server_owned('public.notes', 'id', '…',
606
+ array['status', 'completed_at'], genesis_testing.user_id('client'));
607
+ select genesis_testing.allows_system_paths($$delete from notes where id = '…'$$,
608
+ array['bt.hard_delete=on'], genesis_testing.user_id('client'), 'system work can delete');
609
+ select genesis_testing.never_shows('select * from group_progress(''…'')', 'PRIVATE-ANSWER',
610
+ genesis_testing.user_id('staff'), 'a private answer never reaches the report');
611
+ select genesis_testing.sees('select * from notes', genesis_testing.user_id('client'), 2, 'own notes only');
612
+ ```
613
+
614
+ | Helper | Passes when |
615
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
616
+ | `refuses(sql, user, description)` | The write raises 42501 or changes no row. |
617
+ | `allows(sql, user, description, min_rows)` | It runs without an error (`user` null: signed out, as jobs and backfills run). |
618
+ | `allows_with_setting(sql, setting, value, user, description)` | It runs with a bypass setting such as `bt.hard_delete`. |
619
+ | `allows_system_paths(sql, settings, user, description)` | It runs signed out and under each `name=value` setting (one line each). |
620
+ | `server_owned(table, key_column, key, columns, user)` | The user can't change any of `columns` (one line each), whether the update raises, misses, or a trigger puts it back. |
621
+ | `never_shows(query, marker, user, description)` / `never_shows_anon` | `marker` appears nowhere in what the query returns, for a table, a view or a report function. |
622
+ | `sees(query, user, expected, description)` | The query returns exactly `expected` rows. |
623
+
624
+ A `user` of null means the system: no signed-in user and the test's own role, as jobs, backfills and
625
+ migrations run. Checks only pass on evidence: any error other than a refusal fails them (so a typo
626
+ can't pass), and "changed nothing" counts only when a control run can make the change (so a wrong
627
+ key can't pass). Every check runs in a savepoint that is always rolled back, so checks never affect each other. A
628
+ failing check prints why (`# changed from open to done`, `# raised 23514 …`). The `probe_*`
629
+ functions return the same verdict as jsonb for testing your own helpers. `sign_in(who)`,
630
+ `sign_in_anon()` and `sign_out()` act as someone for the rest of a test, setting both
631
+ `request.jwt.claims` and the legacy `request.jwt.claim.sub`.
632
+
633
+ What to cover, from the review history behind this kit: every guard trigger against every system
634
+ path (signed out, your bypass settings, cascades and ON DELETE SET NULL); every column only the
635
+ server sets; every security-definer function that reads a table with a privacy flag. The fixture
636
+ ids are shared with `@marktiderman/genesis-core/testing`.
637
+
544
638
  ## Blocks
545
639
 
546
640
  `accountBlock` and `tenantFeaturesBlock` are `defineBlock` declarations
@@ -554,5 +648,4 @@ functions above don't change; the declarations are for `resolveBlocks` and the s
554
648
  - **The URL polyfill.** Supabase's React Native guides still import `react-native-url-polyfill/auto` before creating the client. Keep it until your app has tested PKCE sign-in end to end without it: React Native's built-in `URL` has improved, but the docs don't yet say it's enough.
555
649
  - **`refreshWhileActive` on native only.** Supabase gates the AppState listener to native platforms; call it from native code (not on web, where the browser already refreshes in the background).
556
650
 
557
-
558
651
  Part of the [Genesis framework](https://github.com/marktiderman/genesis).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-supabase",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -37,6 +37,7 @@
37
37
  "dist",
38
38
  "functions",
39
39
  "migrations",
40
+ "sql",
40
41
  "README.md"
41
42
  ],
42
43
  "dependencies": {
@@ -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();
@@ -0,0 +1,181 @@
1
+ -- Tests for genesis_testing.sql against a small sample schema with good and deliberately broken
2
+ -- rules, so each helper is shown both passing and catching a failure (through its probe_*).
3
+ -- Run: load genesis_testing.sql, then this file, in one database with pgTAP.
4
+ begin;
5
+ create extension if not exists pgtap with schema extensions;
6
+ select plan(39);
7
+
8
+ select genesis_testing.create_users();
9
+
10
+ -- A sample app: notes owned by a person, with a server-owned status, a guarded delete that
11
+ -- system work may bypass, a child table with ON DELETE SET NULL, and a private flag.
12
+ create type public.gt_mood as enum ('calm', 'busy');
13
+ create table public.gt_notes (
14
+ id uuid primary key,
15
+ owner uuid not null references auth.users (id),
16
+ body text not null,
17
+ status text not null default 'open',
18
+ private boolean not null default false,
19
+ mood public.gt_mood not null default 'calm',
20
+ tags text[] not null default '{}',
21
+ stage text not null default 'draft' check (stage in ('draft', 'sent'))
22
+ );
23
+ create table public.gt_links (
24
+ id uuid primary key,
25
+ note_id uuid references public.gt_notes (id) on delete set null
26
+ );
27
+ alter table public.gt_notes enable row level security;
28
+ alter table public.gt_links enable row level security;
29
+ create policy "own notes" on public.gt_notes for all to authenticated
30
+ using (owner = (select auth.uid())) with check (owner = (select auth.uid()));
31
+ create policy "links" on public.gt_links for all to authenticated using (true) with check (true);
32
+ grant select, insert, update, delete on public.gt_notes, public.gt_links to authenticated;
33
+ grant select on public.gt_notes to anon;
34
+
35
+ -- status is server-owned: a trigger puts it back for signed-in users.
36
+ create function public.gt_notes_guard() returns trigger language plpgsql as $$
37
+ begin
38
+ if (select auth.uid()) is not null and new.status is distinct from old.status then
39
+ new.status := old.status;
40
+ end if;
41
+ return new;
42
+ end $$;
43
+ create trigger gt_notes_guard before update on public.gt_notes for each row execute function public.gt_notes_guard();
44
+
45
+ -- Deleting is refused unless system work (no user, or gt.hard_delete=on).
46
+ create function public.gt_notes_no_delete() returns trigger language plpgsql as $$
47
+ begin
48
+ if (select auth.uid()) is not null and coalesce(current_setting('gt.hard_delete', true), '') <> 'on' then
49
+ raise exception 'Bin it instead' using errcode = '42501';
50
+ end if;
51
+ return old;
52
+ end $$;
53
+ create trigger gt_notes_no_delete before delete on public.gt_notes for each row execute function public.gt_notes_no_delete();
54
+
55
+ -- A report that respects the private flag, and a broken one that leaks it.
56
+ create function public.gt_report_good() returns table (owner uuid, body text)
57
+ language sql security definer set search_path = '' as $$
58
+ select n.owner, n.body from public.gt_notes n where not n.private
59
+ $$;
60
+ create function public.gt_report_leaky() returns table (owner uuid, body text)
61
+ language sql security definer set search_path = '' as $$
62
+ select n.owner, n.body from public.gt_notes n
63
+ $$;
64
+ grant execute on function public.gt_report_good(), public.gt_report_leaky() to authenticated;
65
+
66
+ insert into public.gt_notes (id, owner, body, private) values
67
+ ('00000000-0000-4000-9000-000000000001', genesis_testing.user_id('client'), 'shared note', false),
68
+ ('00000000-0000-4000-9000-000000000002', genesis_testing.user_id('client'), 'SECRET-DIARY', true),
69
+ ('00000000-0000-4000-9000-000000000003', genesis_testing.user_id('owner'), 'owner note', false);
70
+ insert into public.gt_links values ('00000000-0000-4000-9100-000000000001', '00000000-0000-4000-9000-000000000001');
71
+
72
+ -- Fixtures and acting as someone ---------------------------------------------------------------
73
+ select is(genesis_testing.user_id('client'), '00000000-0000-4000-8000-000000000003'::uuid, 'fixture ids are fixed');
74
+ select is((select count(*) from auth.users where email like 'genesis-testing-%@example.test'), 4::bigint, 'four fixture logins');
75
+ select genesis_testing.sign_in('client');
76
+ select is((select auth.uid()), genesis_testing.user_id('client'), 'sign_in sets auth.uid()');
77
+ select is(current_user::text, 'authenticated', 'sign_in acts as authenticated');
78
+ select genesis_testing.sign_out();
79
+ select is((select auth.uid()), null, 'sign_out clears the user');
80
+
81
+ -- refuses ---------------------------------------------------------------------------------------
82
+ select genesis_testing.refuses(
83
+ $$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
84
+ genesis_testing.user_id('stranger'), 'a stranger changes no row of the client''s note');
85
+ select genesis_testing.refuses(
86
+ $$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
87
+ genesis_testing.user_id('client'), 'the client can''t delete outright (42501)');
88
+ select is((genesis_testing.probe_refuses(
89
+ $$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
90
+ genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
91
+ 'refuses fails when the user can make the change');
92
+ select is((select body from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'), 'shared note',
93
+ 'a probe leaves no change behind');
94
+
95
+ -- server_owned ----------------------------------------------------------------------------------
96
+ select genesis_testing.server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
97
+ array['status'], genesis_testing.user_id('client'));
98
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
99
+ 'private', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
100
+ 'server_owned catches a column the client can change');
101
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
102
+ 'nope', genesis_testing.user_id('client')) ->> 'pass')::boolean, false, 'server_owned fails on a missing column');
103
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
104
+ 'body', genesis_testing.user_id('stranger')) ->> 'pass')::boolean, true,
105
+ 'a column RLS keeps the user away from passes too');
106
+
107
+ -- allows and the system paths ------------------------------------------------------------------
108
+ select genesis_testing.allows_system_paths(
109
+ $$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
110
+ array['gt.hard_delete=on'], genesis_testing.user_id('client'), 'the guard lets system deletes through');
111
+ select is((genesis_testing.probe_allows(
112
+ $$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
113
+ genesis_testing.user_id('client')) ->> 'pass')::boolean, false, 'allows fails when the guard refuses');
114
+ select genesis_testing.allows(
115
+ $$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$, null,
116
+ 'ON DELETE SET NULL on a child doesn''t trip a guard', 1);
117
+ select is((genesis_testing.probe_allows(
118
+ $$update public.gt_notes set body = body where id = '00000000-0000-4000-9000-000000000001'$$,
119
+ genesis_testing.user_id('client'), 2) ->> 'pass')::boolean, false, 'allows checks the minimum rows changed');
120
+
121
+ select genesis_testing.allows_system_paths(
122
+ $$select 1 where coalesce(current_setting('gt.mode', true), 'a=b') = 'a=b'$$,
123
+ array['gt.mode=a=b'], genesis_testing.user_id('client'), 'a setting value may contain =');
124
+
125
+ -- never_shows and sees ---------------------------------------------------------------------------
126
+ select genesis_testing.never_shows('select * from public.gt_report_good()', 'SECRET-DIARY',
127
+ genesis_testing.user_id('owner'), 'the good report hides private notes');
128
+ select is((genesis_testing.probe_never_shows('select * from public.gt_report_leaky()', 'SECRET-DIARY',
129
+ genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows catches a leaky report');
130
+ select is((genesis_testing.probe_never_shows($$select 'say "hi"' as v$$, 'say "hi"',
131
+ genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows finds a marker JSON escapes');
132
+ select genesis_testing.never_shows_anon('select * from public.gt_notes', 'SECRET-DIARY',
133
+ 'anon sees no notes (RLS, no anon policy)');
134
+ select genesis_testing.sees('select * from public.gt_notes', genesis_testing.user_id('client'), 2,
135
+ 'the client sees their two notes');
136
+ select is((genesis_testing.probe_sees('select * from public.gt_notes', genesis_testing.user_id('stranger'), 1)
137
+ ->> 'pass')::boolean, false, 'sees fails on the wrong count');
138
+
139
+ -- No false passes ---------------------------------------------------------------------------------
140
+ select is((genesis_testing.probe_refuses(
141
+ $$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-0000000000ff'$$,
142
+ genesis_testing.user_id('stranger')) ->> 'pass')::boolean, false,
143
+ 'refuses fails when the control can''t change a row either (wrong id)');
144
+ select is((genesis_testing.probe_refuses(
145
+ $$update public.gt_notse set body = 'x'$$, genesis_testing.user_id('stranger')) ->> 'pass')::boolean, false,
146
+ 'refuses fails on an error that isn''t a refusal (a typo)');
147
+ select is((genesis_testing.probe_refuses(
148
+ $$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
149
+ genesis_testing.user_id('stranger'), genesis_testing.user_id('owner')) ->> 'pass')::boolean, false,
150
+ 'refuses fails when the named control can''t make the change either');
151
+ select is((genesis_testing.probe_never_shows('select * from public.gt_reprot_good()', 'SECRET-DIARY',
152
+ genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows fails on a typo, not passes');
153
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-0000000000ff',
154
+ 'status', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
155
+ 'server_owned fails on a key that matches no row');
156
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
157
+ 'mood', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
158
+ 'server_owned handles enums: a writable enum column fails');
159
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
160
+ 'tags', genesis_testing.user_id('client')) ->> 'detail'), 'pass a value for tags (type text[]) to try',
161
+ 'server_owned asks for a value for an array column instead of passing');
162
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
163
+ 'tags', genesis_testing.user_id('client'), $$array['x']$$) ->> 'pass')::boolean, false,
164
+ 'with a value, a writable array column fails');
165
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
166
+ 'stage', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
167
+ 'server_owned fails when the tried value breaks a check (the control raises), not passes');
168
+ select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
169
+ 'stage', genesis_testing.user_id('client'), $$'sent'$$) ->> 'pass')::boolean, false,
170
+ 'with a valid value, a writable checked column fails');
171
+
172
+ -- Privileges -------------------------------------------------------------------------------------
173
+ select ok(not has_function_privilege('authenticated', 'genesis_testing.run_as(text, uuid)', 'execute'),
174
+ 'API roles can''t run statements through the kit');
175
+ select ok(not has_function_privilege('anon', 'genesis_testing.sign_in(uuid)', 'execute'),
176
+ 'API roles can''t sign in as someone');
177
+ select ok(has_function_privilege('authenticated', 'genesis_testing.sign_out()', 'execute'),
178
+ 'a test acting as a user can sign out');
179
+
180
+ select * from finish();
181
+ rollback;