@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 +124 -31
- package/package.json +2 -1
- package/sql/testing/genesis_testing.sql +526 -0
- package/sql/testing/tests/genesis_testing.test.sql +181 -0
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 {
|
|
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({
|
|
15
|
-
|
|
16
|
-
|
|
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) =>
|
|
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, {
|
|
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)
|
|
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, {
|
|
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({
|
|
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, {
|
|
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({
|
|
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)
|
|
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
|
|
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
|
|
471
|
-
|
|
472
|
-
| `genesis-support-intake`
|
|
473
|
-
| `genesis-support-notify`
|
|
474
|
-
| `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API
|
|
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 {
|
|
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, {
|
|
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)`
|
|
524
|
-
| `public.tenant_feature_settings(tenant)`
|
|
525
|
-
| `public.tenant_feature_history(tenant, max_rows)`
|
|
526
|
-
| `private.tenant_feature_on(tenant, feature)`
|
|
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
|
+
"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;
|