@rebasepro/server-postgres 0.17.3 → 0.18.1

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 (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. 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
- });