@rebasepro/common 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 (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,866 +0,0 @@
1
- import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection, checkOperation } from "./permissions";
2
- import { AuthState, CollectionConfig, Entity, User, SecurityRule } from "@rebasepro/types";
3
-
4
- describe("Permissions Evaluator", () => {
5
-
6
- // ─── Test fixtures ───────────────────────────────────────────────────────────
7
-
8
- const mockUser: User = {
9
- uid: "user-123",
10
- email: "test@test.com",
11
- displayName: "Test User",
12
- providerId: "test",
13
- isAnonymous: false,
14
- photoURL: null,
15
- roles: ["author", "user"]
16
- };
17
-
18
- const adminUser: User = {
19
- uid: "admin-123",
20
- email: "admin@test.com",
21
- displayName: "Admin User",
22
- providerId: "test",
23
- isAnonymous: false,
24
- photoURL: null,
25
- roles: ["admin"]
26
- };
27
-
28
- // Just the user now. Faking `signOut`, `getAuthToken`, `authLoading` and the
29
- // rest of a frontend controller was never anything these assertions read.
30
- const mockAuthController: AuthState<User> = {
31
- user: mockUser
32
- };
33
-
34
- const adminAuthController: AuthState<User> = {
35
- ...mockAuthController,
36
- user: adminUser
37
- };
38
-
39
- const unauthenticatedController: AuthState<User> = {
40
- ...mockAuthController,
41
- user: null
42
- };
43
-
44
- const createMockCollection = (rules?: SecurityRule[]): CollectionConfig => ({
45
- slug: "test",
46
- name: "Test",
47
- table: "test",
48
- properties: {},
49
- securityRules: rules
50
- });
51
-
52
- const createMockEntity = (values: Record<string, unknown>): Entity => ({
53
- id: "entity-123",
54
- path: "test",
55
- values
56
- });
57
-
58
- // ─── Section 1: Core Defaults ─────────────────────────────────────────────────
59
-
60
- test("1. Implicit fallback: no rules at all returns true (optimistic UI)", () => {
61
- const collection = createMockCollection();
62
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
63
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
64
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
65
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
66
- });
67
-
68
- test("2. Empty rules array [] also returns true", () => {
69
- const collection = createMockCollection([]);
70
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
71
- });
72
-
73
- test("3. Undefined securityRules falls back to true", () => {
74
- const collection = createMockCollection(undefined);
75
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
76
- });
77
-
78
- // ─── Section 2: Operation Filtering ──────────────────────────────────────────
79
-
80
- test("5. Only rules for matching operation are applied", () => {
81
- const collection = createMockCollection([
82
- { operation: "insert",
83
- access: "public" },
84
- { operations: ["select", "delete"],
85
- roles: ["admin"] }
86
- ]);
87
- // Public insert
88
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
89
- // No update rule → denied
90
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
91
- // select/delete need admin → denied for author
92
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
93
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
94
- // Admin can read and delete
95
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
96
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
97
- });
98
-
99
- test("6. 'all' operation matches every CRUD action", () => {
100
- const collection = createMockCollection([
101
- { operation: "all",
102
- roles: ["author"] }
103
- ]);
104
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
105
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
106
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
107
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
108
- // Admin lacks 'author' role
109
- expect(canReadCollection(collection, adminAuthController)).toBe(false);
110
- });
111
-
112
- test("7. 'all' rule grants admin all operations; specific 'update' rule also grants author only update", () => {
113
- const collection = createMockCollection([
114
- { operation: "all",
115
- roles: ["admin"] },
116
- { operation: "update",
117
- roles: ["author"] }
118
- ]);
119
- // Admin: all operations
120
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
121
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
122
- expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
123
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
124
- // Author: only update, not read/create/delete
125
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
126
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
127
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
128
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
129
- });
130
-
131
- test("8. Unknown operation name in rule is silently ignored", () => {
132
- const collection = createMockCollection([
133
- { operation: "read" as never,
134
- access: "public" } // "read" not a valid op
135
- ]);
136
- // "read" does not match "select", so it is never applied → denied
137
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
138
- });
139
-
140
- // ─── Section 3: Role Matching ─────────────────────────────────────────────────
141
-
142
- test("9. Role check: user with matching role is granted", () => {
143
- const collection = createMockCollection([
144
- { operation: "all",
145
- roles: ["admin", "editor"] }
146
- ]);
147
- expect(canReadCollection(collection, mockAuthController)).toBe(false); // author
148
- expect(canReadCollection(collection, adminAuthController)).toBe(true); // admin
149
- });
150
-
151
- // The test that used to sit here claimed roles arrive as `{ id, name }`
152
- // objects and are mapped to strings. `User.roles` is `string[]` and no such
153
- // mapping exists anywhere, so the fixture was plain strings and the
154
- // assertion was test 9 again with a different rule. What is worth pinning
155
- // instead is that the match is an exact string membership test.
156
- test("10. Role IDs match exactly — not by prefix, substring or case", () => {
157
- const collection = createMockCollection([
158
- { operation: "select",
159
- roles: ["author"] }
160
- ]);
161
- const withRoles = (roles: string[] | undefined): AuthState<User> => ({
162
- user: { ...mockUser,
163
- roles }
164
- });
165
-
166
- expect(canReadCollection(collection, withRoles(["author", "user"]))).toBe(true);
167
- expect(canReadCollection(collection, withRoles(["authors"]))).toBe(false);
168
- expect(canReadCollection(collection, withRoles(["auth"]))).toBe(false);
169
- expect(canReadCollection(collection, withRoles(["Author"]))).toBe(false);
170
- expect(canReadCollection(collection, withRoles([]))).toBe(false);
171
- expect(canReadCollection(collection, withRoles(undefined))).toBe(false);
172
- });
173
-
174
- test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
175
- const collection = createMockCollection([
176
- { operation: "insert",
177
- access: "public",
178
- roles: [] }
179
- ]);
180
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
181
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
182
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
183
- });
184
-
185
- test("12. Undefined roles on a public rule grants access to everyone", () => {
186
- const collection = createMockCollection([
187
- { operation: "insert",
188
- access: "public" }
189
- ]);
190
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
191
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
192
- });
193
-
194
- test("13. No rules match user's role → implicit deny", () => {
195
- const collection = createMockCollection([
196
- { operation: "all",
197
- roles: ["admin"] },
198
- { operation: "all",
199
- roles: ["moderator"] }
200
- ]);
201
- expect(canReadCollection(collection, mockAuthController)).toBe(false); // author
202
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
203
- });
204
-
205
- test("14. 'public' pseudo-role in rule.roles grants access to unauthenticated users", () => {
206
- const collection = createMockCollection([
207
- { operation: "select",
208
- roles: ["public"] }
209
- ]);
210
- expect(canReadCollection(collection, unauthenticatedController)).toBe(true);
211
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
212
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
213
- });
214
-
215
- test("15. Multi-role overlap: user has ['author','user'], rule needs ['editor','user','moderator']", () => {
216
- const collection = createMockCollection([
217
- { operation: "insert",
218
- roles: ["editor", "user", "moderator"] }
219
- ]);
220
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true); // 'user' matches
221
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(false); // 'admin' doesn't match
222
- });
223
-
224
- test("16. Unknown role in rule denies everyone", () => {
225
- const collection = createMockCollection([
226
- { operation: "insert",
227
- roles: ["unknown_role_xyz"] }
228
- ]);
229
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
230
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(false);
231
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
232
- });
233
-
234
- // ─── Section 4: Access Shortcuts (access: "public") ───────────────────────────
235
-
236
- test("17. access:'public' bypasses all role and SQL checks immediately", () => {
237
- const collection = createMockCollection([
238
- { operation: "select",
239
- access: "public" }
240
- ]);
241
- expect(canReadCollection(collection, unauthenticatedController)).toBe(true);
242
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
243
- });
244
-
245
- // ─── Section 5: Owner Fields ───────────────────────────────────────────────────
246
-
247
- test("18. ownerField: user can edit their own entity, not others'", () => {
248
- const collection = createMockCollection([
249
- { operation: "update",
250
- ownerField: "user_id" }
251
- ]);
252
- const owned = createMockEntity({ user_id: "user-123" });
253
- const notOwned = createMockEntity({ user_id: "other-user" });
254
- expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
255
- expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
256
- });
257
-
258
- test("19. ownerField: unauthenticated user (no uid) cannot own any entity", () => {
259
- const collection = createMockCollection([
260
- { operation: "update",
261
- ownerField: "user_id" }
262
- ]);
263
- const entity = createMockEntity({ user_id: "user-123" });
264
- expect(canEditEntity(collection, unauthenticatedController, "test", entity)).toBe(false);
265
- });
266
-
267
- test("20. ownerField with null entity is OPTIMISTICALLY enabled (UI has no data yet)", () => {
268
- // NOTE: This is by design — the UI cannot know ownership before entity exists.
269
- // The backend will enforce the actual denial. This is documented behavior.
270
- const collection = createMockCollection([
271
- { operation: "update",
272
- ownerField: "user_uid" }
273
- ]);
274
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
275
- // Even unauthenticated! The button shows, backend blocks.
276
- expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(true);
277
- });
278
-
279
- test("21. ownerField: admin cannot bypass ownerField without an 'admin' role rule", () => {
280
- const collection = createMockCollection([
281
- { operation: "update",
282
- ownerField: "user_id" }
283
- ]);
284
- const adminOwnedEntity = createMockEntity({ user_id: "admin-123" });
285
- const regularEntity = createMockEntity({ user_id: "user-123" });
286
- // Admin owns this entity
287
- expect(canEditEntity(collection, adminAuthController, "test", adminOwnedEntity)).toBe(true);
288
- // Admin does NOT own this entity — denied!
289
- expect(canEditEntity(collection, adminAuthController, "test", regularEntity)).toBe(false);
290
- });
291
-
292
- test("22. ownerField used in a delete rule", () => {
293
- const collection = createMockCollection([
294
- { operation: "delete",
295
- ownerField: "author_uid" }
296
- ]);
297
- const owned = createMockEntity({ author_uid: "user-123" });
298
- const notOwned = createMockEntity({ author_uid: "someone-else" });
299
- expect(canDeleteEntity(collection, mockAuthController, "test", owned)).toBe(true);
300
- expect(canDeleteEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
301
- });
302
-
303
- // ─── Section 6: SQL AST Evaluation ───────────────────────────────────────────
304
-
305
- test("23. AST: simple equality check", () => {
306
- const collection = createMockCollection([
307
- { operation: "update",
308
- using: "status = 'published'" }
309
- ]);
310
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(true);
311
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(false);
312
- });
313
-
314
- test("24. AST: simple inequality check", () => {
315
- const collection = createMockCollection([
316
- { operation: "update",
317
- using: "status != 'archived'" }
318
- ]);
319
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
320
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "archived" }))).toBe(false);
321
- });
322
-
323
- test("25. AST: current_setting('app.user_id') comparison", () => {
324
- const collection = createMockCollection([
325
- { operation: "update",
326
- using: "author_uid = current_setting('app.user_id')" }
327
- ]);
328
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
329
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
330
- });
331
-
332
- test("26. AST: current_setting reversed operand order", () => {
333
- const collection = createMockCollection([
334
- { operation: "update",
335
- using: "current_setting('app.user_id') = author_uid" }
336
- ]);
337
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
338
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
339
- });
340
-
341
- test("27. AST: current_setting with extra whitespace in SQL string", () => {
342
- const collection = createMockCollection([
343
- { operation: "update",
344
- using: "author_uid=current_setting ( 'app.user_id' )" }
345
- ]);
346
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
347
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
348
- });
349
-
350
- test("28. AST: complex SQL with AND/OR is evaluated by the parser", () => {
351
- const collection = createMockCollection([
352
- { operation: "update",
353
- using: "status IN ('published', 'draft') AND author_id = current_setting('app.user_id')" }
354
- ]);
355
- // IN clause is optimistic true, but AND requires author_id to match user uid
356
- const mismatchEntity = createMockEntity({ status: "archived",
357
- author_id: "someone-else" });
358
- expect(canEditEntity(collection, mockAuthController, "test", mismatchEntity)).toBe(false);
359
-
360
- // When author_id matches the current user, AND passes
361
- const matchingEntity = createMockEntity({ status: "published",
362
- author_id: "user-123" });
363
- expect(canEditEntity(collection, mockAuthController, "test", matchingEntity)).toBe(true);
364
- });
365
-
366
- test("29. AST: null entity ALWAYS optimistically passes SQL evaluation", () => {
367
- const collection = createMockCollection([
368
- { operation: "update",
369
- using: "status = 'published'" }
370
- ]);
371
- // Even a simple parseable SQL works optimistically with null entity
372
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
373
- });
374
-
375
- // ─── Section 7: using AND withCheck (both must pass) ─────────────────────────
376
-
377
- test("30. using AND withCheck: both independently evaluated, both must pass", () => {
378
- const collection = createMockCollection([
379
- { operation: "update",
380
- using: "status = 'draft'",
381
- withCheck: "title_length > 5" }
382
- ]);
383
- // As the entity values are strings, title_length > 5 won't parse cleanly → optimistic true for withCheck
384
- // status = 'draft' WILL parse and must match
385
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
386
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(false);
387
- });
388
-
389
- test("31. using passes but withCheck fails → rule denied", () => {
390
- const collection = createMockCollection([
391
- { operation: "update",
392
- using: "status = 'draft'",
393
- withCheck: "visibility = 'public'" }
394
- ]);
395
- const entity = createMockEntity({ status: "draft",
396
- visibility: "private" });
397
- // using passes (status is 'draft'), withCheck fails (visibility is 'private')
398
- expect(canEditEntity(collection, mockAuthController, "test", entity)).toBe(false);
399
- });
400
-
401
- test("32. withCheck without using: evaluated independently", () => {
402
- const collection = createMockCollection([
403
- { operation: "update",
404
- using: "true",
405
- withCheck: "status = 'draft'" }
406
- ]);
407
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
408
- expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(false);
409
- });
410
-
411
- // ─── Section 8: Permissive Mode ───────────────────────────────────────────────
412
-
413
- test("33. Two permissive rules = logical OR", () => {
414
- const collection = createMockCollection([
415
- { operation: "update",
416
- roles: ["admin"],
417
- mode: "permissive" },
418
- { operation: "update",
419
- ownerField: "user_id",
420
- mode: "permissive" }
421
- ]);
422
- const owned = createMockEntity({ user_id: "user-123" });
423
- const notOwned = createMockEntity({ user_id: "other" });
424
- // Admin: first rule passes
425
- expect(canEditEntity(collection, adminAuthController, "test", notOwned)).toBe(true);
426
- // Author: second rule passes for owned entity
427
- expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
428
- // Author: neither rule passes for unowned entity
429
- expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
430
- });
431
-
432
- test("34. A single permissive rule that fails → denied", () => {
433
- const collection = createMockCollection([
434
- { operation: "update",
435
- roles: ["admin"],
436
- mode: "permissive" }
437
- ]);
438
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
439
- });
440
-
441
- // ─── Section 9: Restrictive Mode ────────────────────────────────────────────
442
-
443
- test("35. A sole restrictive rule (no permissive) ALWAYS denies", () => {
444
- // In PostgreSQL: restrictive policies only filter from a permissive base.
445
- // No permissive base = no access.
446
- const collection = createMockCollection([
447
- { operation: "select",
448
- mode: "restrictive",
449
- using: "status = 'draft'" }
450
- ]);
451
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
452
- expect(canReadCollection(collection, adminAuthController)).toBe(false);
453
- expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
454
- });
455
-
456
- test("36. Permissive base + restrictive filter: entity matching restrictor passes", () => {
457
- const collection = createMockCollection([
458
- { operation: "update",
459
- access: "public",
460
- mode: "permissive" },
461
- { operation: "update",
462
- mode: "restrictive",
463
- using: "owner_id = current_setting('app.user_id')" }
464
- ]);
465
- const owned = createMockEntity({ owner_id: "user-123" });
466
- const notOwned = createMockEntity({ owner_id: "other-user" });
467
- expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
468
- expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
469
- });
470
-
471
- test("37. Restrictive rule with role filter: only applies to users with that role", () => {
472
- // A restrictive rule with roles: ["author"] applies ONLY to users with author role.
473
- // Users WITHOUT the role are NOT subject to this restrictive rule.
474
- const collection = createMockCollection([
475
- { operation: "update",
476
- access: "public",
477
- mode: "permissive" },
478
- { operation: "update",
479
- roles: ["author"],
480
- mode: "restrictive",
481
- using: "status = 'published'" }
482
- ]);
483
- const draftEntity = createMockEntity({ status: "draft" });
484
- const publishedEntity = createMockEntity({ status: "published" });
485
-
486
- // Admin has no "author" role → restrictive rule does NOT apply to admin → permissive grants true
487
- expect(canEditEntity(collection, adminAuthController, "test", draftEntity)).toBe(true);
488
-
489
- // Author HAS "author" role → restrictive rule applies → draft fails restrictive → denied
490
- expect(canEditEntity(collection, mockAuthController, "test", draftEntity)).toBe(false);
491
-
492
- // Author + published → restrictive passes → permissive grants true
493
- expect(canEditEntity(collection, mockAuthController, "test", publishedEntity)).toBe(true);
494
- });
495
-
496
- test("38. Restrictive rule fails immediately, does not continue to permissive", () => {
497
- const collection = createMockCollection([
498
- { operation: "insert",
499
- roles: ["author"],
500
- mode: "permissive" },
501
- { operation: "insert",
502
- roles: ["author"],
503
- mode: "restrictive",
504
- using: "status = 'draft'" }
505
- ]);
506
-
507
- // With null entity: evaluateAST("status = 'draft'", ..., null) → true (optimistic)
508
- // So restrictive passes → permissive also passes → true
509
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
510
-
511
- // With real entity that fails the restrictive check: immediate deny
512
- const publishedEntity = createMockEntity({ status: "published" });
513
- expect(canCreateEntity(collection, mockAuthController, "test", publishedEntity)).toBe(false);
514
-
515
- const draftEntity = createMockEntity({ status: "draft" });
516
- expect(canCreateEntity(collection, mockAuthController, "test", draftEntity)).toBe(true);
517
- });
518
-
519
- // ─── Section 10: UI Enablability (null entity = optimistic) ─────────────────
520
-
521
- describe("UI enablability with null entities", () => {
522
-
523
- test("39. Create: role-gated rule → disabled for non-matching user", () => {
524
- const collection = createMockCollection([{ operation: "insert",
525
- roles: ["admin"] }]);
526
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
527
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
528
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
529
- });
530
-
531
- test("40. Edit: role-gated rule → disabled for non-matching user", () => {
532
- const collection = createMockCollection([{ operation: "update",
533
- roles: ["admin"] }]);
534
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
535
- expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
536
- });
537
-
538
- test("41. Delete: role-gated rule → disabled for non-matching user", () => {
539
- const collection = createMockCollection([{ operation: "delete",
540
- roles: ["admin"] }]);
541
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
542
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
543
- });
544
-
545
- test("42. Read: role-gated rule → disabled for non-matching user", () => {
546
- const collection = createMockCollection([{ operation: "select",
547
- roles: ["admin"] }]);
548
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
549
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
550
- });
551
-
552
- test("43. Create: complex SQL condition → optimistically enabled (null entity)", () => {
553
- const collection = createMockCollection([
554
- { operation: "insert",
555
- using: "status = 'published' AND id IN (SELECT id FROM drafts)" }
556
- ]);
557
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
558
- });
559
-
560
- test("44. Edit: ownerField with null entity → optimistically enabled", () => {
561
- const collection = createMockCollection([{ operation: "update",
562
- ownerField: "user_uid" }]);
563
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
564
- // Even unauth! Backend will block.
565
- expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(true);
566
- });
567
-
568
- test("45. Delete: public rule → everyone enabled", () => {
569
- const collection = createMockCollection([{ operation: "delete",
570
- access: "public" }]);
571
- expect(canDeleteEntity(collection, unauthenticatedController, "test", null)).toBe(true);
572
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
573
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
574
- });
575
-
576
- test("46. Read: sole restrictive rule → disabled for everyone", () => {
577
- const collection = createMockCollection([
578
- { operation: "select",
579
- mode: "restrictive",
580
- using: "status = 'draft'" }
581
- ]);
582
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
583
- expect(canReadCollection(collection, adminAuthController)).toBe(false);
584
- expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
585
- });
586
-
587
- test("47. Create: public permissive + data-level restrictive → optimistically true (null entity)", () => {
588
- const collection = createMockCollection([
589
- { operation: "insert",
590
- access: "public",
591
- mode: "permissive" },
592
- { operation: "insert",
593
- mode: "restrictive",
594
- using: "status = 'published'" }
595
- ]);
596
- // null entity → restrictive evaluateAST returns true (optimistic) → passes → permissive grants → true
597
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
598
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
599
- });
600
-
601
- test("48. Create: admin-permissive + author-restrictive → admin enabled, author disabled (null entity)", () => {
602
- const collection = createMockCollection([
603
- { operation: "insert",
604
- roles: ["admin"],
605
- mode: "permissive" },
606
- { operation: "insert",
607
- roles: ["author"],
608
- mode: "restrictive",
609
- using: "status = 'draft'" }
610
- ]);
611
- // Admin: only the permissive rule applies → true
612
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
613
- // Author: restrictive rule applies + passes (null-optimistic), BUT no permissive rule → false
614
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
615
- // Unauth: no rules apply → false
616
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
617
- });
618
-
619
- test("49. Edit: any rule without matching op → disabled", () => {
620
- const collection = createMockCollection([
621
- { operation: "insert",
622
- access: "public" }
623
- ]);
624
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
625
- expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(false);
626
- });
627
-
628
- test("50. All CRUD disabled when all rules have unknown role", () => {
629
- const collection = createMockCollection([
630
- { operation: "all",
631
- roles: ["nonexistent_role"] }
632
- ]);
633
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
634
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
635
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
636
- expect(canReadCollection(collection, mockAuthController)).toBe(false);
637
- });
638
- });
639
-
640
- // ─── Section 11: Multi-Rule Combinations ──────────────────────────────────────
641
-
642
- describe("Multi-rule combinations", () => {
643
-
644
- test("51. Admin OR author permissive → both can create, unauth cannot", () => {
645
- const collection = createMockCollection([
646
- { operation: "insert",
647
- roles: ["admin"],
648
- mode: "permissive" },
649
- { operation: "insert",
650
- roles: ["author"],
651
- mode: "permissive" }
652
- ]);
653
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
654
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
655
- expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
656
- });
657
-
658
- test("52. Admin can delete anything, author only their own entities", () => {
659
- const collection = createMockCollection([
660
- { operation: "delete",
661
- roles: ["admin"] },
662
- { operation: "delete",
663
- ownerField: "author_uid" }
664
- ]);
665
- const authorEntity = createMockEntity({ author_uid: "user-123" });
666
- const hackerEntity = createMockEntity({ author_uid: "hacker" });
667
-
668
- expect(canDeleteEntity(collection, adminAuthController, "test", authorEntity)).toBe(true);
669
- expect(canDeleteEntity(collection, adminAuthController, "test", hackerEntity)).toBe(true);
670
- expect(canDeleteEntity(collection, mockAuthController, "test", authorEntity)).toBe(true);
671
- expect(canDeleteEntity(collection, mockAuthController, "test", hackerEntity)).toBe(false);
672
- });
673
-
674
- test("53. Role OR owner combinded: admin by role, others by ownership", () => {
675
- const collection = createMockCollection([
676
- { operation: "update",
677
- roles: ["admin"],
678
- mode: "permissive" },
679
- { operation: "update",
680
- ownerField: "user_id",
681
- mode: "permissive" }
682
- ]);
683
- const owned = createMockEntity({ user_id: "user-123" });
684
- const notOwned = createMockEntity({ user_id: "other" });
685
-
686
- expect(canEditEntity(collection, adminAuthController, "test", notOwned)).toBe(true);
687
- expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
688
- expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
689
- });
690
-
691
- test("54. 'all' rule permissive base + specific delete restrictive", () => {
692
- const collection = createMockCollection([
693
- { operation: "all",
694
- access: "public",
695
- mode: "permissive" },
696
- { operation: "delete",
697
- mode: "restrictive",
698
- using: "is_deletable = 'true'" }
699
- ]);
700
- const deletable = createMockEntity({ is_deletable: "true" });
701
- const notDeletable = createMockEntity({ is_deletable: "false" });
702
-
703
- // Reading still works (only 'all' permissive applies to select)
704
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
705
- // Deletable entity passes restrictive
706
- expect(canDeleteEntity(collection, mockAuthController, "test", deletable)).toBe(true);
707
- // Non-deletable entity fails restrictive
708
- expect(canDeleteEntity(collection, mockAuthController, "test", notDeletable)).toBe(false);
709
- });
710
-
711
- test("55. Three-user-role scenario: platform role hierarchy", () => {
712
- const editorUser: User = {
713
- uid: "editor-1",
714
- email: "e@e.com",
715
- displayName: "Ed",
716
- providerId: "test",
717
- isAnonymous: false,
718
- photoURL: null,
719
- roles: ["editor"]
720
- };
721
- const editorAuth: AuthState<User> = { ...mockAuthController,
722
- user: editorUser };
723
-
724
- const collection = createMockCollection([
725
- { operation: "select",
726
- roles: ["admin", "editor", "author"] }, // read all three
727
- { operation: "insert",
728
- roles: ["admin", "editor"] }, // create admin/editor
729
- { operation: "update",
730
- roles: ["admin"] }, // edit admin only
731
- { operation: "delete",
732
- roles: ["admin"] } // delete admin only
733
- ]);
734
-
735
- // Admin
736
- expect(canReadCollection(collection, adminAuthController)).toBe(true);
737
- expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
738
- expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
739
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
740
- // Editor
741
- expect(canReadCollection(collection, editorAuth)).toBe(true);
742
- expect(canCreateEntity(collection, editorAuth, "test", null)).toBe(true);
743
- expect(canEditEntity(collection, editorAuth, "test", null)).toBe(false);
744
- expect(canDeleteEntity(collection, editorAuth, "test", null)).toBe(false);
745
- // Author (mockUser)
746
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
747
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
748
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
749
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
750
- // Unauth
751
- expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
752
- });
753
-
754
- test("56. Publication workflow: draft/review/published state machine", () => {
755
- const collection = createMockCollection([
756
- // Anyone can read published content
757
- { operation: "select",
758
- using: "status = 'published'" },
759
- // Authors can only create drafts
760
- { operation: "insert",
761
- roles: ["author"],
762
- using: "true",
763
- withCheck: "status = 'draft'" },
764
- // Authors can only edit their own draft or review content
765
- { operation: "update",
766
- roles: ["author"],
767
- using: "author_id = current_setting('app.user_id') AND status != 'published'" },
768
- // Editors can move anything to/from review/published
769
- { operation: "update",
770
- roles: ["editor"] },
771
- // Only admins can delete
772
- { operation: "delete",
773
- roles: ["admin"] }
774
- ]);
775
-
776
- const draft = createMockEntity({ status: "draft",
777
- author_id: "user-123" });
778
- const published = createMockEntity({ status: "published",
779
- author_id: "user-123" });
780
- const othersDraft = createMockEntity({ status: "draft",
781
- author_id: "someone-else" });
782
-
783
- // Author can create (null entity, optimistic)
784
- expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
785
- // Author can edit their own draft
786
- expect(canEditEntity(collection, mockAuthController, "test", draft)).toBe(true);
787
- // Author CANNOT edit their own published content (status = 'published' fails using)
788
- expect(canEditEntity(collection, mockAuthController, "test", published)).toBe(false);
789
- // Author CANNOT edit others' drafts
790
- expect(canEditEntity(collection, mockAuthController, "test", othersDraft)).toBe(false);
791
- // Author CANNOT delete
792
- expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
793
- // Admin CAN delete
794
- expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
795
- });
796
-
797
- test("57. Unauthenticated user: ownerField rule denies, SQL rule without roles still grants", () => {
798
- // Three update rules, each independently permissive (OR logic).
799
- // Rule 1: admin-only role → unauthenticated lacks it → filtered out
800
- // Rule 2: ownerField user_id → entity.user_id = null, auth.uid = undefined → null !== undefined → fails
801
- // Rule 3: using status = 'draft' → passes, has no role restriction → grants true
802
- // Result: true (rule 3 wins)
803
- const collection = createMockCollection([
804
- { operation: "update",
805
- roles: ["admin"] },
806
- { operation: "update",
807
- ownerField: "user_id" },
808
- { operation: "update",
809
- using: "status = 'draft'" }
810
- ]);
811
- const draftOwned = createMockEntity({ user_id: null,
812
- status: "draft" });
813
- expect(canEditEntity(collection, unauthenticatedController, "test", draftOwned)).toBe(true);
814
-
815
- // To actually restrict unauthenticated users, the 3rd rule needs a role guard:
816
- const restrictedCollection = createMockCollection([
817
- { operation: "update",
818
- roles: ["admin"] },
819
- { operation: "update",
820
- ownerField: "user_id" },
821
- { operation: "update",
822
- roles: ["author"],
823
- using: "status = 'draft'" }
824
- ]);
825
- expect(canEditEntity(collection, unauthenticatedController, "test", draftOwned)).toBe(true);
826
-
827
- // `true`, not `false`, and the change is in `evaluatePolicy` rather
828
- // than here. The ownerField rule compares `user_id` (null on this
829
- // row) against the uid, and a comparison against NULL is now
830
- // "unknown" — SQL's answer — where it used to be a flat `false`.
831
- // `canEditEntity` gates optimistically (`onUnknown: "allow"`), so
832
- // an undecided rule shows the button.
833
- //
834
- // The old `false` matched Postgres here by accident and diverged
835
- // from it under negation; see the note on the null branch in
836
- // `evaluatePolicy`. What this now exposes is a separate, real gap:
837
- // "the client cannot evaluate this" (raw SQL, a membership
838
- // subquery) and "SQL would answer NULL, so the row is definitively
839
- // not granted" are both spelled "unknown", and only the first
840
- // deserves optimism. Recorded in docs/verification.md.
841
- expect(canEditEntity(restrictedCollection, unauthenticatedController, "test", draftOwned)).toBe(true);
842
-
843
- // Fail-closed callers — every enforcement path — are unaffected:
844
- // an undecided rule denies.
845
- expect(checkOperation(
846
- restrictedCollection, unauthenticatedController, draftOwned, "update", { onUnknown: "deny" }
847
- )).toBe(false);
848
- });
849
-
850
- test("58. Missing authController ('blankAuth') handled gracefully", () => {
851
- const collection = createMockCollection([{ operation: "insert",
852
- roles: ["admin"] }]);
853
- const blankAuth = { user: null } as unknown as AuthState<User>;
854
- expect(canCreateEntity(collection, blankAuth, "test", null)).toBe(false);
855
- });
856
-
857
- test("59. SQL injection-like string in 'using' is safely treated as unparseable → optimistic", () => {
858
- const collection = createMockCollection([
859
- { operation: "update",
860
- using: "\"); DROP TABLE users; --" }
861
- ]);
862
- // Frontend never executes SQL, just parses. Malformed string → optimistic pass
863
- expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
864
- });
865
- });
866
- });