@rebasepro/server-postgres 0.17.3 → 0.18.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.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,435 +0,0 @@
1
- import { describe, expect, it } from "@jest/globals";
2
- import type { CollectionConfig } from "@rebasepro/types";
3
-
4
- import { checkPolicyDrift, parseExpectedPolicies, formatPolicyDrift, hasDrift, dropOrphanedPolicies, isGeneratedPolicyName, type PolicyRef, type Queryable } from "./policy-drift";
5
- import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
6
-
7
- function collection(slug: string): CollectionConfig {
8
- return {
9
- name: slug,
10
- slug,
11
- table: slug,
12
- schema: "public",
13
- properties: { id: { name: "ID", type: "string", isId: "uuid" } },
14
- securityRules: [
15
- { operation: "select", access: "public" },
16
- { operations: ["insert", "update", "delete"], roles: ["admin"] }
17
- ]
18
- } as unknown as CollectionConfig;
19
- }
20
-
21
- /** A collection whose write rule is declared RESTRICTIVE, so the DDL says `AS RESTRICTIVE`. */
22
- function restrictiveCollection(slug: string): CollectionConfig {
23
- return {
24
- name: slug,
25
- slug,
26
- table: slug,
27
- schema: "public",
28
- properties: { id: { name: "ID", type: "string", isId: "uuid" } },
29
- securityRules: [
30
- { operation: "select", access: "public" },
31
- { operations: ["insert", "update", "delete"], roles: ["admin"], mode: "restrictive" }
32
- ]
33
- } as unknown as CollectionConfig;
34
- }
35
-
36
- /**
37
- * Stands in for the two catalogue reads: `pg_policies`, then `pg_class`.
38
- *
39
- * The second one is dispatched on the query text rather than call order, so a
40
- * test that stops caring about ordering does not silently start feeding policy
41
- * rows to the RLS-status read — which would report every table as RLS-off,
42
- * since a policy row has no `relrowsecurity` column and `undefined` is falsy.
43
- *
44
- * `rlsOff` names tables whose RLS switch is off. Anything not listed is on,
45
- * which is what a healthy `db push` leaves behind.
46
- */
47
- function dbWith(rows: Record<string, unknown>[], rlsOff: string[] = []): Queryable {
48
- return {
49
- query: async (text: string, values?: unknown[]) => {
50
- if (!text.includes("pg_class")) return { rows: rows as never[] };
51
- const wanted = (values?.[0] as string[] | undefined) ?? [];
52
- return {
53
- rows: wanted.map((qualified) => {
54
- const [schemaname, tablename] = qualified.split(".");
55
- return {
56
- schemaname,
57
- tablename,
58
- relrowsecurity: !rlsOff.includes(tablename),
59
- relforcerowsecurity: !rlsOff.includes(tablename)
60
- };
61
- }) as never[]
62
- };
63
- }
64
- };
65
- }
66
-
67
- /** A pg_policies row matching an expected policy, clauses and all. */
68
- function liveRow(p: PolicyRef, overrides: Record<string, unknown> = {}) {
69
- return {
70
- schemaname: p.schema, tablename: p.table, policyname: p.name, roles: p.roles, cmd: p.command,
71
- // Postgres rewrites the text it stores; only presence is compared.
72
- qual: p.hasUsing ? "(rewritten by postgres)" : null,
73
- with_check: p.hasWithCheck ? "(rewritten by postgres)" : null,
74
- permissive: p.mode ?? "PERMISSIVE",
75
- ...overrides
76
- };
77
- }
78
-
79
- describe("parseExpectedPolicies", () => {
80
- it("reads the DDL that db push actually applies", () => {
81
- const ddl = generatePostgresPoliciesDdl([collection("authors")]);
82
- const parsed = parseExpectedPolicies(ddl);
83
-
84
- expect(parsed.length).toBeGreaterThan(0);
85
- const select = parsed.find((p) => p.command === "SELECT" && p.roles.includes("public"));
86
- expect(select).toMatchObject({ schema: "public", table: "authors", command: "SELECT" });
87
- });
88
- });
89
-
90
- describe("checkPolicyDrift", () => {
91
- it("reports nothing when the database matches the collections", async () => {
92
- const cols = [collection("authors")];
93
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
94
-
95
- const drift = await checkPolicyDrift(dbWith(expected.map((p) => liveRow(p))), cols);
96
-
97
- expect(hasDrift(drift)).toBe(false);
98
- expect(formatPolicyDrift(drift)).toBe("");
99
- });
100
-
101
- it("flags a table whose RLS switch is off, though every policy still matches", async () => {
102
- // The whole point: DISABLE ROW LEVEL SECURITY leaves pg_policies
103
- // untouched, so name, roles, command and clause presence all agree and
104
- // every other check here passes. Before this category the report was
105
- // empty on a table Postgres was filtering nothing on.
106
- const cols = [collection("authors")];
107
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
108
-
109
- const drift = await checkPolicyDrift(
110
- dbWith(expected.map((p) => liveRow(p)), ["authors"]),
111
- cols
112
- );
113
-
114
- expect(drift.missing).toHaveLength(0);
115
- expect(drift.diverged).toHaveLength(0);
116
- expect(drift.orphaned).toHaveLength(0);
117
- expect(drift.rlsDisabled).toEqual([{ schema: "public", table: "authors", forced: false }]);
118
- expect(hasDrift(drift)).toBe(true);
119
- expect(formatPolicyDrift(drift)).toContain("RLS DISABLED");
120
- });
121
-
122
- it("flags a RESTRICTIVE rule the database stores as PERMISSIVE", async () => {
123
- // A gate that should be ANDed in is now ORed in — it widens access
124
- // instead of narrowing it, with identical name, roles, command and
125
- // clauses. Only the `AS` clause differs, and it used to be discarded by
126
- // the DDL parser and never selected from pg_policies.
127
- const cols = [restrictiveCollection("ledger")];
128
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
129
- const restrictive = expected.filter((p) => p.mode === "RESTRICTIVE");
130
- // Guards the test itself: if the generator stops emitting AS RESTRICTIVE
131
- // this must fail loudly rather than pass by having nothing to compare.
132
- expect(restrictive.length).toBeGreaterThan(0);
133
-
134
- const live = expected.map((p) =>
135
- p.name === restrictive[0].name ? liveRow(p, { permissive: "PERMISSIVE" }) : liveRow(p)
136
- );
137
-
138
- const drift = await checkPolicyDrift(dbWith(live), cols);
139
-
140
- expect(drift.diverged).toHaveLength(1);
141
- expect(drift.diverged[0].differences.join(" ")).toContain("mode: expected RESTRICTIVE");
142
- expect(formatPolicyDrift(drift)).toContain("widens access");
143
- });
144
-
145
- it("does not invent drift when either side's mode is unreadable", async () => {
146
- // An older server or a driver that hands back a shape we do not know
147
- // must not turn into a wall of false positives.
148
- const cols = [collection("authors")];
149
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
150
-
151
- const drift = await checkPolicyDrift(
152
- dbWith(expected.map((p) => liveRow(p, { permissive: null }))),
153
- cols
154
- );
155
-
156
- expect(drift.diverged).toHaveLength(0);
157
- expect(hasDrift(drift)).toBe(false);
158
- });
159
-
160
- it("flags a policy the database never received as missing", async () => {
161
- const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
162
-
163
- expect(drift.missing.length).toBeGreaterThan(0);
164
- expect(formatPolicyDrift(drift)).toContain("Missing");
165
- expect(formatPolicyDrift(drift)).toContain("rebase db push");
166
- });
167
-
168
- it("flags a stale policy left by an earlier push as orphaned", async () => {
169
- // The demo's actual bug: test_policy TO authenticated outlived the
170
- // config that created it, and kept filtering every row.
171
- const cols = [collection("customers")];
172
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
173
- const live = [
174
- ...expected.map((p) => liveRow(p)),
175
- { schemaname: "public", tablename: "customers", policyname: "test_policy", roles: ["authenticated"], cmd: "ALL", qual: "true", with_check: null }
176
- ];
177
-
178
- const drift = await checkPolicyDrift(dbWith(live), cols);
179
-
180
- expect(drift.orphaned).toHaveLength(1);
181
- expect(drift.orphaned[0]).toMatchObject({ name: "test_policy", roles: ["authenticated"] });
182
- expect(formatPolicyDrift(drift)).toContain("Orphaned");
183
- });
184
-
185
- it("flags a policy whose roles were changed underneath it as diverged", async () => {
186
- const cols = [collection("orders")];
187
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
188
- // Someone re-granted it to a role requests never run as.
189
- const live = expected.map((p) => liveRow(p, { roles: ["authenticated"] }));
190
-
191
- const drift = await checkPolicyDrift(dbWith(live), cols);
192
-
193
- expect(drift.diverged.length).toBeGreaterThan(0);
194
- expect(drift.diverged[0].differences.join(" ")).toContain("roles");
195
- expect(formatPolicyDrift(drift)).toContain("Diverged");
196
- });
197
-
198
- it("flags a policy whose expression the database is missing entirely", async () => {
199
- // A real production database had jobs.public_read_published stored as
200
- // SELECT / {public} / USING NULL: every field this used to compare
201
- // matched, drift reported zero, and the policy denied 100% of reads.
202
- const cols = [collection("jobs")];
203
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
204
- const select = expected.find((p) => p.command === "SELECT" && p.roles.includes("public"))!;
205
- const live = expected.map((p) => (p === select ? liveRow(p, { qual: null }) : liveRow(p)));
206
-
207
- const drift = await checkPolicyDrift(dbWith(live), cols);
208
-
209
- expect(drift.diverged).toHaveLength(1);
210
- expect(drift.diverged[0].differences.join(" ")).toContain("USING: expected an expression, database has none");
211
- });
212
-
213
- it("does not mistake an expression Postgres rewrote for a missing one", async () => {
214
- // The reason expression text is not compared: what comes back out of
215
- // pg_policies never matches what went in.
216
- const cols = [collection("posts")];
217
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
218
- const live = expected.map((p) => liveRow(p, {
219
- qual: p.hasUsing ? "((auth.uid() IS NOT NULL) AND ((auth.uid())::text <> 'anonymous'::text))" : null
220
- }));
221
-
222
- const drift = await checkPolicyDrift(dbWith(live), cols);
223
-
224
- expect(hasDrift(drift)).toBe(false);
225
- });
226
-
227
- it("flags the pre-fix permissive tautology that every other check misses", async () => {
228
- // A database pushed before the `policy.authenticated()` fix carries
229
- // `auth.uid() IS NOT NULL` — true for anonymous visitors. Its name,
230
- // roles, command and clause presence all match the corrected policy, so
231
- // this is the only signal that catches it.
232
- const cols = [collection("posts")];
233
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
234
- const live = expected.map((p) => liveRow(p, {
235
- qual: p.hasUsing ? "(auth.uid() IS NOT NULL)" : null
236
- }));
237
-
238
- const drift = await checkPolicyDrift(dbWith(live), cols);
239
-
240
- expect(drift.insecure.length).toBeGreaterThan(0);
241
- expect(hasDrift(drift)).toBe(true);
242
- expect(drift.diverged).toHaveLength(0); // nothing else notices
243
- expect(formatPolicyDrift(drift)).toContain("anonymous");
244
- expect(formatPolicyDrift(drift)).toContain("db push");
245
- });
246
-
247
- it("flags the tautology under the post-1.0 schema name too", async () => {
248
- // The helpers moved from `auth` to `rebase` in 1.0, and the compiler
249
- // rewrites raw `securityRules` SQL on the way in — so `rebase.uid() IS
250
- // NOT NULL` is the only spelling a current release can store. A checker
251
- // anchored on the pre-1.0 name saw none of it.
252
- const cols = [collection("posts")];
253
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
254
- const live = expected.map((p) => liveRow(p, {
255
- qual: p.hasUsing ? "(rebase.uid() IS NOT NULL)" : null
256
- }));
257
-
258
- const drift = await checkPolicyDrift(dbWith(live), cols);
259
-
260
- expect(drift.insecure.length).toBeGreaterThan(0);
261
- expect(drift.diverged).toHaveLength(0);
262
- });
263
-
264
- it("clears the corrected expression, in either literal spelling", async () => {
265
- const cols = [collection("posts")];
266
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
267
- for (const fn of ["auth.uid()", "rebase.uid()"]) {
268
- for (const guard of ["<> 'anonymous'::text", "<> 'anonymous'", "!= 'anonymous'"]) {
269
- const live = expected.map((p) => liveRow(p, {
270
- qual: p.hasUsing ? `((${fn} IS NOT NULL) AND ((${fn})::text ${guard}))` : null
271
- }));
272
- const drift = await checkPolicyDrift(dbWith(live), cols);
273
- expect(drift.insecure).toHaveLength(0);
274
- }
275
- }
276
- });
277
-
278
- it("also flags the tautology in a WITH CHECK clause", async () => {
279
- const cols = [collection("posts")];
280
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
281
- const withCheck = expected.filter((p) => p.hasWithCheck);
282
- // Asserted rather than assumed: if the generator ever stopped emitting a
283
- // WITH CHECK for these rules the fixture would carry no tautology at
284
- // all, and every assertion below would be vacuously satisfied.
285
- expect(withCheck.length).toBeGreaterThan(0);
286
-
287
- const live = expected.map((p) => liveRow(p, {
288
- with_check: p.hasWithCheck ? "(auth.uid() IS NOT NULL)" : null
289
- }));
290
-
291
- const drift = await checkPolicyDrift(dbWith(live), cols);
292
-
293
- // Every WITH CHECK policy carries the tautology and no USING clause
294
- // does, so anything flagged here came from the WITH CHECK scan — the
295
- // half that a check reading only `qual` would miss entirely.
296
- expect(drift.insecure.map((i) => i.policy.name)).toEqual(withCheck.map((p) => p.name));
297
- expect(drift.insecure.every((i) => /WITH CHECK/.test(i.reason))).toBe(true);
298
- });
299
-
300
- it("parses roles when the driver returns the raw {a,b} text form", async () => {
301
- const cols = [collection("tags")];
302
- const live = [{ schemaname: "public", tablename: "tags", policyname: "test_policy", roles: "{authenticated,anon}", cmd: "ALL", qual: "true", with_check: null }];
303
-
304
- const drift = await checkPolicyDrift(dbWith(live), cols);
305
-
306
- expect(drift.orphaned[0].roles).toEqual(["authenticated", "anon"]);
307
- });
308
-
309
- it("reports nothing when there are no collections at all", async () => {
310
- // With no collections there is no expectation to reconcile against, so
311
- // scanning would report every policy in the database as orphaned.
312
- // Note a collection with no securityRules is NOT this case — it still
313
- // generates default (admin-only) policies.
314
- const drift = await checkPolicyDrift(
315
- dbWith([{ schemaname: "public", tablename: "x", policyname: "p", roles: ["public"], cmd: "ALL", qual: "true", with_check: null }]),
316
- []
317
- );
318
-
319
- expect(hasDrift(drift)).toBe(false);
320
- });
321
-
322
- it("still expects the default policies a collection without securityRules generates", async () => {
323
- const bare = { name: "x", slug: "x", table: "x", schema: "public", properties: {} } as unknown as CollectionConfig;
324
-
325
- const drift = await checkPolicyDrift(dbWith([]), [bare]);
326
-
327
- // Locked-by-default: absence of rules means admin-only policies, not none.
328
- expect(drift.missing.length).toBeGreaterThan(0);
329
- });
330
- });
331
-
332
- describe("isGeneratedPolicyName", () => {
333
- it("recognises the generator's own shape", () => {
334
- expect(isGeneratedPolicyName("documents_insert_a1b2c3d", "documents")).toBe(true);
335
- // One rule spanning several operations appends the operation index.
336
- expect(isGeneratedPolicyName("documents_update_a1b2c3d_1", "documents")).toBe(true);
337
- });
338
-
339
- it("does not claim names a human could have written", () => {
340
- expect(isGeneratedPolicyName("owner_access", "documents")).toBe(false);
341
- expect(isGeneratedPolicyName("documents_default_admin_read", "documents")).toBe(false);
342
- // Right shape, wrong table — belongs to something else.
343
- expect(isGeneratedPolicyName("teams_insert_a1b2c3d", "documents")).toBe(false);
344
- // A digest is 7 lowercase hex characters, nothing else.
345
- expect(isGeneratedPolicyName("documents_insert_notahex", "documents")).toBe(false);
346
- expect(isGeneratedPolicyName("documents_grant_a1b2c3d", "documents")).toBe(false);
347
- });
348
- });
349
-
350
- describe("dropOrphanedPolicies", () => {
351
- /** Records the DDL issued so the test can assert on what was dropped. */
352
- function recordingDb(rows: Record<string, unknown>[]) {
353
- const executed: string[] = [];
354
- const db: Queryable = {
355
- query: async (text: string) => {
356
- if (!/^SELECT/i.test(text)) executed.push(text);
357
- return { rows: rows as never[] };
358
- }
359
- };
360
- return { db, executed };
361
- }
362
-
363
- it("drops the policy a rule edit superseded", async () => {
364
- // The reported failure: tightening a rule renames its policy, and the
365
- // permissive original stays live and keeps ORing itself back in.
366
- const cols = [collection("documents")];
367
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
368
- const stale = {
369
- schemaname: "public", tablename: "documents", policyname: "documents_insert_dead1ee",
370
- roles: ["public"], cmd: "INSERT", qual: null, with_check: "true"
371
- };
372
- const live = [...expected.map((p) => liveRow(p)), stale];
373
-
374
- const drift = await checkPolicyDrift(dbWith(live), cols);
375
- const { db, executed } = recordingDb(live);
376
- const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
377
-
378
- expect(dropped).toHaveLength(1);
379
- expect(dropped[0].name).toBe("documents_insert_dead1ee");
380
- expect(kept).toHaveLength(0);
381
- expect(executed).toEqual([
382
- 'DROP POLICY IF EXISTS "documents_insert_dead1ee" ON "public"."documents"'
383
- ]);
384
- });
385
-
386
- it("leaves a hand-written policy alone and reports it instead", async () => {
387
- const cols = [collection("documents")];
388
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
389
- const live = [
390
- ...expected.map((p) => liveRow(p)),
391
- { schemaname: "public", tablename: "documents", policyname: "ops_break_glass", roles: ["public"], cmd: "ALL", qual: "true", with_check: null }
392
- ];
393
-
394
- const drift = await checkPolicyDrift(dbWith(live), cols);
395
- const { db, executed } = recordingDb(live);
396
- const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
397
-
398
- expect(dropped).toHaveLength(0);
399
- expect(kept.map((p) => p.name)).toEqual(["ops_break_glass"]);
400
- expect(executed).toEqual([]);
401
- });
402
-
403
- it("never touches a table the collections do not describe", async () => {
404
- // Another application sharing the schema owns this table; a
405
- // generator-shaped name there is coincidence, not our leftover.
406
- const cols = [collection("documents")];
407
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
408
- const live = [
409
- ...expected.map((p) => liveRow(p)),
410
- { schemaname: "public", tablename: "legacy", policyname: "legacy_select_a1b2c3d", roles: ["public"], cmd: "SELECT", qual: "true", with_check: null }
411
- ];
412
-
413
- const drift = await checkPolicyDrift(dbWith(live), cols);
414
- const { db, executed } = recordingDb(live);
415
- const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
416
-
417
- expect(dropped).toHaveLength(0);
418
- expect(kept.map((p) => p.name)).toEqual(["legacy_select_a1b2c3d"]);
419
- expect(executed).toEqual([]);
420
- });
421
-
422
- it("does nothing when the database already matches", async () => {
423
- const cols = [collection("documents")];
424
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
425
- const live = expected.map((p) => liveRow(p));
426
-
427
- const drift = await checkPolicyDrift(dbWith(live), cols);
428
- const { db, executed } = recordingDb(live);
429
- const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
430
-
431
- expect(dropped).toHaveLength(0);
432
- expect(kept).toHaveLength(0);
433
- expect(executed).toEqual([]);
434
- });
435
- });