@rebasepro/common 0.0.1-canary.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/collections/CollectionRegistry.d.ts +48 -0
  4. package/dist/collections/index.d.ts +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.es.js +2380 -0
  7. package/dist/index.es.js.map +1 -0
  8. package/dist/index.umd.js +2379 -0
  9. package/dist/index.umd.js.map +1 -0
  10. package/dist/util/arrays.d.ts +1 -0
  11. package/dist/util/builders.d.ts +64 -0
  12. package/dist/util/callbacks.d.ts +6 -0
  13. package/dist/util/collections.d.ts +11 -0
  14. package/dist/util/common.d.ts +2 -0
  15. package/dist/util/conditions.d.ts +26 -0
  16. package/dist/util/dates.d.ts +1 -0
  17. package/dist/util/entities.d.ts +28 -0
  18. package/dist/util/entity_actions.d.ts +2 -0
  19. package/dist/util/enums.d.ts +3 -0
  20. package/dist/util/fields.d.ts +2 -0
  21. package/dist/util/flatten_object.d.ts +5 -0
  22. package/dist/util/hash.d.ts +1 -0
  23. package/dist/util/index.d.ts +26 -0
  24. package/dist/util/names.d.ts +22 -0
  25. package/dist/util/navigation_from_path.d.ts +29 -0
  26. package/dist/util/navigation_utils.d.ts +31 -0
  27. package/dist/util/objects.d.ts +26 -0
  28. package/dist/util/os.d.ts +2 -0
  29. package/dist/util/parent_references_from_path.d.ts +6 -0
  30. package/dist/util/paths.d.ts +14 -0
  31. package/dist/util/permissions.d.ts +5 -0
  32. package/dist/util/permissions.test.d.ts +1 -0
  33. package/dist/util/plurals.d.ts +16 -0
  34. package/dist/util/references.d.ts +2 -0
  35. package/dist/util/regexp.d.ts +7 -0
  36. package/dist/util/relations.d.ts +12 -0
  37. package/dist/util/resolutions.d.ts +74 -0
  38. package/dist/util/storage.d.ts +24 -0
  39. package/dist/util/strings.d.ts +7 -0
  40. package/package.json +118 -0
  41. package/src/collections/CollectionRegistry.ts +319 -0
  42. package/src/collections/index.ts +1 -0
  43. package/src/index.ts +2 -0
  44. package/src/util/arrays.ts +3 -0
  45. package/src/util/builders.ts +138 -0
  46. package/src/util/callbacks.ts +115 -0
  47. package/src/util/collections.ts +126 -0
  48. package/src/util/common.ts +2 -0
  49. package/src/util/conditions.ts +348 -0
  50. package/src/util/dates.ts +1 -0
  51. package/src/util/entities.ts +212 -0
  52. package/src/util/entity_actions.ts +28 -0
  53. package/src/util/enums.ts +26 -0
  54. package/src/util/fields.ts +28 -0
  55. package/src/util/flatten_object.ts +45 -0
  56. package/src/util/hash.ts +11 -0
  57. package/src/util/index.ts +26 -0
  58. package/src/util/names.ts +30 -0
  59. package/src/util/navigation_from_path.ts +121 -0
  60. package/src/util/navigation_utils.ts +222 -0
  61. package/src/util/objects.ts +376 -0
  62. package/src/util/os.ts +13 -0
  63. package/src/util/parent_references_from_path.ts +57 -0
  64. package/src/util/paths.ts +27 -0
  65. package/src/util/permissions.test.ts +716 -0
  66. package/src/util/permissions.ts +235 -0
  67. package/src/util/plurals.ts +188 -0
  68. package/src/util/references.ts +34 -0
  69. package/src/util/regexp.ts +32 -0
  70. package/src/util/relations.ts +211 -0
  71. package/src/util/resolutions.ts +383 -0
  72. package/src/util/storage.ts +144 -0
  73. package/src/util/strings.ts +84 -0
@@ -0,0 +1,716 @@
1
+ import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection } from "./permissions";
2
+ import { EntityCollection, AuthController, Entity, User } 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
+ const mockAuthController: AuthController<any> = {
29
+ user: mockUser,
30
+ initialLoading: false,
31
+ authLoading: false,
32
+ loginSkipped: false,
33
+ signOut: async () => { },
34
+ getAuthToken: async () => "token",
35
+ setExtra: () => { },
36
+ extra: {}
37
+ };
38
+
39
+ const adminAuthController: AuthController<any> = {
40
+ ...mockAuthController,
41
+ user: adminUser
42
+ };
43
+
44
+ const unauthenticatedController: AuthController<any> = {
45
+ ...mockAuthController,
46
+ user: null
47
+ };
48
+
49
+ const createMockCollection = (rules?: any[]): EntityCollection<any> => ({
50
+ id: "test",
51
+ name: "Test",
52
+ properties: {},
53
+ securityRules: rules
54
+ } as any);
55
+
56
+ const createMockEntity = (values: Record<string, any>): Entity<any> => ({
57
+ id: "entity-123",
58
+ path: "test",
59
+ values,
60
+ originalValues: values,
61
+ } as any);
62
+
63
+ // ─── Section 1: Core Defaults ─────────────────────────────────────────────────
64
+
65
+ test("1. Implicit fallback: no rules at all returns true (optimistic UI)", () => {
66
+ const collection = createMockCollection();
67
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
68
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
69
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
70
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
71
+ });
72
+
73
+ test("2. Empty rules array [] also returns true", () => {
74
+ const collection = createMockCollection([]);
75
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
76
+ });
77
+
78
+ test("3. Null securityRules falls back to true", () => {
79
+ const collection = createMockCollection(null as any);
80
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
81
+ });
82
+
83
+ test("4. Collection group ALWAYS blocks create, any other operation fine", () => {
84
+ const collection = { ...createMockCollection(), collectionGroup: true };
85
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
86
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
87
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
88
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
89
+ });
90
+
91
+ // ─── Section 2: Operation Filtering ──────────────────────────────────────────
92
+
93
+ test("5. Only rules for matching operation are applied", () => {
94
+ const collection = createMockCollection([
95
+ { operation: "insert", access: "public" },
96
+ { operations: ["select", "delete"], roles: ["admin"] }
97
+ ]);
98
+ // Public insert
99
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
100
+ // No update rule → denied
101
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
102
+ // select/delete need admin → denied for author
103
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
104
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
105
+ // Admin can read and delete
106
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
107
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
108
+ });
109
+
110
+ test("6. 'all' operation matches every CRUD action", () => {
111
+ const collection = createMockCollection([
112
+ { operation: "all", roles: ["author"] }
113
+ ]);
114
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
115
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
116
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
117
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
118
+ // Admin lacks 'author' role
119
+ expect(canReadCollection(collection, adminAuthController)).toBe(false);
120
+ });
121
+
122
+ test("7. 'all' rule grants admin all operations; specific 'update' rule also grants author only update", () => {
123
+ const collection = createMockCollection([
124
+ { operation: "all", roles: ["admin"] },
125
+ { operation: "update", roles: ["author"] }
126
+ ]);
127
+ // Admin: all operations
128
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
129
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
130
+ expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
131
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
132
+ // Author: only update, not read/create/delete
133
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
134
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
135
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
136
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
137
+ });
138
+
139
+ test("8. Unknown operation name in rule is silently ignored", () => {
140
+ const collection = createMockCollection([
141
+ { operation: "read" as any, access: "public" } // "read" not a valid op
142
+ ]);
143
+ // "read" does not match "select", so it is never applied → denied
144
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
145
+ });
146
+
147
+ // ─── Section 3: Role Matching ─────────────────────────────────────────────────
148
+
149
+ test("9. Role check: user with matching role is granted", () => {
150
+ const collection = createMockCollection([
151
+ { operation: "all", roles: ["admin", "editor"] }
152
+ ]);
153
+ expect(canReadCollection(collection, mockAuthController)).toBe(false); // author
154
+ expect(canReadCollection(collection, adminAuthController)).toBe(true); // admin
155
+ });
156
+
157
+ test("10. Roles on user are objects {id, name} — correctly mapped to strings", () => {
158
+ const collection = createMockCollection([
159
+ { operation: "select", roles: ["author"] }
160
+ ]);
161
+ // mockUser.roles = [{ id: "author" }, { id: "user" }]
162
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
163
+ });
164
+
165
+ test("11. Empty roles array [] on rule grants access to everyone (public)", () => {
166
+ const collection = createMockCollection([
167
+ { operation: "insert", roles: [] }
168
+ ]);
169
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
170
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
171
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
172
+ });
173
+
174
+ test("12. Undefined roles on rule grants access to everyone (public)", () => {
175
+ const collection = createMockCollection([
176
+ { operation: "insert" }
177
+ ]);
178
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
179
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
180
+ });
181
+
182
+ test("13. No rules match user's role → implicit deny", () => {
183
+ const collection = createMockCollection([
184
+ { operation: "all", roles: ["admin"] },
185
+ { operation: "all", roles: ["moderator"] }
186
+ ]);
187
+ expect(canReadCollection(collection, mockAuthController)).toBe(false); // author
188
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
189
+ });
190
+
191
+ test("14. 'public' pseudo-role in rule.roles grants access to unauthenticated users", () => {
192
+ const collection = createMockCollection([
193
+ { operation: "select", roles: ["public"] }
194
+ ]);
195
+ expect(canReadCollection(collection, unauthenticatedController)).toBe(true);
196
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
197
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
198
+ });
199
+
200
+ test("15. Multi-role overlap: user has ['author','user'], rule needs ['editor','user','moderator']", () => {
201
+ const collection = createMockCollection([
202
+ { operation: "insert", roles: ["editor", "user", "moderator"] }
203
+ ]);
204
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true); // 'user' matches
205
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(false); // 'admin' doesn't match
206
+ });
207
+
208
+ test("16. Unknown role in rule denies everyone", () => {
209
+ const collection = createMockCollection([
210
+ { operation: "insert", roles: ["unknown_role_xyz"] }
211
+ ]);
212
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
213
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(false);
214
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
215
+ });
216
+
217
+ // ─── Section 4: Access Shortcuts (access: "public") ───────────────────────────
218
+
219
+ test("17. access:'public' bypasses all role and SQL checks immediately", () => {
220
+ const collection = createMockCollection([
221
+ { operation: "select", access: "public" }
222
+ ]);
223
+ expect(canReadCollection(collection, unauthenticatedController)).toBe(true);
224
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
225
+ });
226
+
227
+ // ─── Section 5: Owner Fields ───────────────────────────────────────────────────
228
+
229
+ test("18. ownerField: user can edit their own entity, not others'", () => {
230
+ const collection = createMockCollection([
231
+ { operation: "update", ownerField: "user_id" }
232
+ ]);
233
+ const owned = createMockEntity({ user_id: "user-123" });
234
+ const notOwned = createMockEntity({ user_id: "other-user" });
235
+ expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
236
+ expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
237
+ });
238
+
239
+ test("19. ownerField: unauthenticated user (no uid) cannot own any entity", () => {
240
+ const collection = createMockCollection([
241
+ { operation: "update", ownerField: "user_id" }
242
+ ]);
243
+ const entity = createMockEntity({ user_id: "user-123" });
244
+ expect(canEditEntity(collection, unauthenticatedController, "test", entity)).toBe(false);
245
+ });
246
+
247
+ test("20. ownerField with null entity is OPTIMISTICALLY enabled (UI has no data yet)", () => {
248
+ // NOTE: This is by design — the UI cannot know ownership before entity exists.
249
+ // The backend will enforce the actual denial. This is documented behavior.
250
+ const collection = createMockCollection([
251
+ { operation: "update", ownerField: "user_uid" }
252
+ ]);
253
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
254
+ // Even unauthenticated! The button shows, backend blocks.
255
+ expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(true);
256
+ });
257
+
258
+ test("21. ownerField: admin cannot bypass ownerField without an 'admin' role rule", () => {
259
+ const collection = createMockCollection([
260
+ { operation: "update", ownerField: "user_id" }
261
+ ]);
262
+ const adminOwnedEntity = createMockEntity({ user_id: "admin-123" });
263
+ const regularEntity = createMockEntity({ user_id: "user-123" });
264
+ // Admin owns this entity
265
+ expect(canEditEntity(collection, adminAuthController, "test", adminOwnedEntity)).toBe(true);
266
+ // Admin does NOT own this entity — denied!
267
+ expect(canEditEntity(collection, adminAuthController, "test", regularEntity)).toBe(false);
268
+ });
269
+
270
+ test("22. ownerField used in a delete rule", () => {
271
+ const collection = createMockCollection([
272
+ { operation: "delete", ownerField: "author_uid" }
273
+ ]);
274
+ const owned = createMockEntity({ author_uid: "user-123" });
275
+ const notOwned = createMockEntity({ author_uid: "someone-else" });
276
+ expect(canDeleteEntity(collection, mockAuthController, "test", owned)).toBe(true);
277
+ expect(canDeleteEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
278
+ });
279
+
280
+ // ─── Section 6: SQL AST Evaluation ───────────────────────────────────────────
281
+
282
+ test("23. AST: simple equality check", () => {
283
+ const collection = createMockCollection([
284
+ { operation: "update", using: "status = 'published'" }
285
+ ]);
286
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(true);
287
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(false);
288
+ });
289
+
290
+ test("24. AST: simple inequality check", () => {
291
+ const collection = createMockCollection([
292
+ { operation: "update", using: "status != 'archived'" }
293
+ ]);
294
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
295
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "archived" }))).toBe(false);
296
+ });
297
+
298
+ test("25. AST: current_setting('app.user_id') comparison", () => {
299
+ const collection = createMockCollection([
300
+ { operation: "update", using: "author_uid = current_setting('app.user_id')" }
301
+ ]);
302
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
303
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
304
+ });
305
+
306
+ test("26. AST: current_setting reversed operand order", () => {
307
+ const collection = createMockCollection([
308
+ { operation: "update", using: "current_setting('app.user_id') = author_uid" }
309
+ ]);
310
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
311
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
312
+ });
313
+
314
+ test("27. AST: current_setting with extra whitespace in SQL string", () => {
315
+ const collection = createMockCollection([
316
+ { operation: "update", using: "author_uid=current_setting ( 'app.user_id' )" }
317
+ ]);
318
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "user-123" }))).toBe(true);
319
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ author_uid: "other" }))).toBe(false);
320
+ });
321
+
322
+ test("28. AST: complex SQL with AND/OR optimistically passes (entity present)", () => {
323
+ const collection = createMockCollection([
324
+ { operation: "update", using: "status IN ('published', 'draft') AND author_id = current_setting('app.user_id')" }
325
+ ]);
326
+ // Complex queries are beyond our parser → optimistic true even with entity
327
+ const anyEntity = createMockEntity({ status: "archived", author_id: "someone-else" });
328
+ expect(canEditEntity(collection, mockAuthController, "test", anyEntity)).toBe(true);
329
+ });
330
+
331
+ test("29. AST: null entity ALWAYS optimistically passes SQL evaluation", () => {
332
+ const collection = createMockCollection([
333
+ { operation: "update", using: "status = 'published'" }
334
+ ]);
335
+ // Even a simple parseable SQL works optimistically with null entity
336
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
337
+ });
338
+
339
+ // ─── Section 7: using AND withCheck (both must pass) ─────────────────────────
340
+
341
+ test("30. using AND withCheck: both independently evaluated, both must pass", () => {
342
+ const collection = createMockCollection([
343
+ { operation: "update", using: "status = 'draft'", withCheck: "title_length > 5" }
344
+ ]);
345
+ // As the entity values are strings, title_length > 5 won't parse cleanly → optimistic true for withCheck
346
+ // status = 'draft' WILL parse and must match
347
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
348
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(false);
349
+ });
350
+
351
+ test("31. using passes but withCheck fails → rule denied", () => {
352
+ const collection = createMockCollection([
353
+ { operation: "update", using: "status = 'draft'", withCheck: "visibility = 'public'" }
354
+ ]);
355
+ const entity = createMockEntity({ status: "draft", visibility: "private" });
356
+ // using passes (status is 'draft'), withCheck fails (visibility is 'private')
357
+ expect(canEditEntity(collection, mockAuthController, "test", entity)).toBe(false);
358
+ });
359
+
360
+ test("32. withCheck without using: evaluated independently", () => {
361
+ const collection = createMockCollection([
362
+ { operation: "update", withCheck: "status = 'draft'" }
363
+ ]);
364
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "draft" }))).toBe(true);
365
+ expect(canEditEntity(collection, mockAuthController, "test", createMockEntity({ status: "published" }))).toBe(false);
366
+ });
367
+
368
+ // ─── Section 8: Permissive Mode ───────────────────────────────────────────────
369
+
370
+ test("33. Two permissive rules = logical OR", () => {
371
+ const collection = createMockCollection([
372
+ { operation: "update", roles: ["admin"], mode: "permissive" },
373
+ { operation: "update", ownerField: "user_id", mode: "permissive" }
374
+ ]);
375
+ const owned = createMockEntity({ user_id: "user-123" });
376
+ const notOwned = createMockEntity({ user_id: "other" });
377
+ // Admin: first rule passes
378
+ expect(canEditEntity(collection, adminAuthController, "test", notOwned)).toBe(true);
379
+ // Author: second rule passes for owned entity
380
+ expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
381
+ // Author: neither rule passes for unowned entity
382
+ expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
383
+ });
384
+
385
+ test("34. A single permissive rule that fails → denied", () => {
386
+ const collection = createMockCollection([
387
+ { operation: "update", roles: ["admin"], mode: "permissive" }
388
+ ]);
389
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
390
+ });
391
+
392
+ // ─── Section 9: Restrictive Mode ────────────────────────────────────────────
393
+
394
+ test("35. A sole restrictive rule (no permissive) ALWAYS denies", () => {
395
+ // In PostgreSQL: restrictive policies only filter from a permissive base.
396
+ // No permissive base = no access.
397
+ const collection = createMockCollection([
398
+ { operation: "select", mode: "restrictive", using: "status = 'draft'" }
399
+ ]);
400
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
401
+ expect(canReadCollection(collection, adminAuthController)).toBe(false);
402
+ expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
403
+ });
404
+
405
+ test("36. Permissive base + restrictive filter: entity matching restrictor passes", () => {
406
+ const collection = createMockCollection([
407
+ { operation: "update", access: "public", mode: "permissive" },
408
+ { operation: "update", mode: "restrictive", using: "owner_id = current_setting('app.user_id')" }
409
+ ]);
410
+ const owned = createMockEntity({ owner_id: "user-123" });
411
+ const notOwned = createMockEntity({ owner_id: "other-user" });
412
+ expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
413
+ expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
414
+ });
415
+
416
+ test("37. Restrictive rule with role filter: only applies to users with that role", () => {
417
+ // A restrictive rule with roles: ["author"] applies ONLY to users with author role.
418
+ // Users WITHOUT the role are NOT subject to this restrictive rule.
419
+ const collection = createMockCollection([
420
+ { operation: "update", access: "public", mode: "permissive" },
421
+ { operation: "update", roles: ["author"], mode: "restrictive", using: "status = 'published'" }
422
+ ]);
423
+ const draftEntity = createMockEntity({ status: "draft" });
424
+ const publishedEntity = createMockEntity({ status: "published" });
425
+
426
+ // Admin has no "author" role → restrictive rule does NOT apply to admin → permissive grants true
427
+ expect(canEditEntity(collection, adminAuthController, "test", draftEntity)).toBe(true);
428
+
429
+ // Author HAS "author" role → restrictive rule applies → draft fails restrictive → denied
430
+ expect(canEditEntity(collection, mockAuthController, "test", draftEntity)).toBe(false);
431
+
432
+ // Author + published → restrictive passes → permissive grants true
433
+ expect(canEditEntity(collection, mockAuthController, "test", publishedEntity)).toBe(true);
434
+ });
435
+
436
+ test("38. Restrictive rule fails immediately, does not continue to permissive", () => {
437
+ const collection = createMockCollection([
438
+ { operation: "insert", roles: ["author"], mode: "permissive" },
439
+ { operation: "insert", roles: ["author"], mode: "restrictive", using: "status = 'draft'" }
440
+ ]);
441
+
442
+ // With null entity: evaluateAST("status = 'draft'", ..., null) → true (optimistic)
443
+ // So restrictive passes → permissive also passes → true
444
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
445
+
446
+ // With real entity that fails the restrictive check: immediate deny
447
+ const publishedEntity = createMockEntity({ status: "published" });
448
+ expect(canCreateEntity(collection, mockAuthController, "test", publishedEntity)).toBe(false);
449
+
450
+ const draftEntity = createMockEntity({ status: "draft" });
451
+ expect(canCreateEntity(collection, mockAuthController, "test", draftEntity)).toBe(true);
452
+ });
453
+
454
+ // ─── Section 10: UI Enablability (null entity = optimistic) ─────────────────
455
+
456
+ describe("UI enablability with null entities", () => {
457
+
458
+ test("39. Create: role-gated rule → disabled for non-matching user", () => {
459
+ const collection = createMockCollection([{ operation: "insert", roles: ["admin"] }]);
460
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
461
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
462
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
463
+ });
464
+
465
+ test("40. Edit: role-gated rule → disabled for non-matching user", () => {
466
+ const collection = createMockCollection([{ operation: "update", roles: ["admin"] }]);
467
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
468
+ expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
469
+ });
470
+
471
+ test("41. Delete: role-gated rule → disabled for non-matching user", () => {
472
+ const collection = createMockCollection([{ operation: "delete", roles: ["admin"] }]);
473
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
474
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
475
+ });
476
+
477
+ test("42. Read: role-gated rule → disabled for non-matching user", () => {
478
+ const collection = createMockCollection([{ operation: "select", roles: ["admin"] }]);
479
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
480
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
481
+ });
482
+
483
+ test("43. Create: complex SQL condition → optimistically enabled (null entity)", () => {
484
+ const collection = createMockCollection([
485
+ { operation: "insert", using: "status = 'published' AND id IN (SELECT id FROM drafts)" }
486
+ ]);
487
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
488
+ });
489
+
490
+ test("44. Edit: ownerField with null entity → optimistically enabled", () => {
491
+ const collection = createMockCollection([{ operation: "update", ownerField: "user_uid" }]);
492
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
493
+ // Even unauth! Backend will block.
494
+ expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(true);
495
+ });
496
+
497
+ test("45. Delete: public rule → everyone enabled", () => {
498
+ const collection = createMockCollection([{ operation: "delete", access: "public" }]);
499
+ expect(canDeleteEntity(collection, unauthenticatedController, "test", null)).toBe(true);
500
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(true);
501
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
502
+ });
503
+
504
+ test("46. Read: sole restrictive rule → disabled for everyone", () => {
505
+ const collection = createMockCollection([
506
+ { operation: "select", mode: "restrictive", using: "status = 'draft'" }
507
+ ]);
508
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
509
+ expect(canReadCollection(collection, adminAuthController)).toBe(false);
510
+ expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
511
+ });
512
+
513
+ test("47. Create: public permissive + data-level restrictive → optimistically true (null entity)", () => {
514
+ const collection = createMockCollection([
515
+ { operation: "insert", access: "public", mode: "permissive" },
516
+ { operation: "insert", mode: "restrictive", using: "status = 'published'" }
517
+ ]);
518
+ // null entity → restrictive evaluateAST returns true (optimistic) → passes → permissive grants → true
519
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
520
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
521
+ });
522
+
523
+ test("48. Create: admin-permissive + author-restrictive → admin enabled, author disabled (null entity)", () => {
524
+ const collection = createMockCollection([
525
+ { operation: "insert", roles: ["admin"], mode: "permissive" },
526
+ { operation: "insert", roles: ["author"], mode: "restrictive", using: "status = 'draft'" }
527
+ ]);
528
+ // Admin: only the permissive rule applies → true
529
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
530
+ // Author: restrictive rule applies + passes (null-optimistic), BUT no permissive rule → false
531
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
532
+ // Unauth: no rules apply → false
533
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
534
+ });
535
+
536
+ test("49. Edit: any rule without matching op → disabled", () => {
537
+ const collection = createMockCollection([
538
+ { operation: "insert", access: "public" }
539
+ ]);
540
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
541
+ expect(canEditEntity(collection, unauthenticatedController, "test", null)).toBe(false);
542
+ });
543
+
544
+ test("50. All CRUD disabled when all rules have unknown role", () => {
545
+ const collection = createMockCollection([
546
+ { operation: "all", roles: ["nonexistent_role"] }
547
+ ]);
548
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
549
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
550
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
551
+ expect(canReadCollection(collection, mockAuthController)).toBe(false);
552
+ });
553
+ });
554
+
555
+ // ─── Section 11: Multi-Rule Combinations ──────────────────────────────────────
556
+
557
+ describe("Multi-rule combinations", () => {
558
+
559
+ test("51. Admin OR author permissive → both can create, unauth cannot", () => {
560
+ const collection = createMockCollection([
561
+ { operation: "insert", roles: ["admin"], mode: "permissive" },
562
+ { operation: "insert", roles: ["author"], mode: "permissive" }
563
+ ]);
564
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
565
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
566
+ expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(false);
567
+ });
568
+
569
+ test("52. Admin can delete anything, author only their own entities", () => {
570
+ const collection = createMockCollection([
571
+ { operation: "delete", roles: ["admin"] },
572
+ { operation: "delete", ownerField: "author_uid" }
573
+ ]);
574
+ const authorEntity = createMockEntity({ author_uid: "user-123" });
575
+ const hackerEntity = createMockEntity({ author_uid: "hacker" });
576
+
577
+ expect(canDeleteEntity(collection, adminAuthController, "test", authorEntity)).toBe(true);
578
+ expect(canDeleteEntity(collection, adminAuthController, "test", hackerEntity)).toBe(true);
579
+ expect(canDeleteEntity(collection, mockAuthController, "test", authorEntity)).toBe(true);
580
+ expect(canDeleteEntity(collection, mockAuthController, "test", hackerEntity)).toBe(false);
581
+ });
582
+
583
+ test("53. Role OR owner combinded: admin by role, others by ownership", () => {
584
+ const collection = createMockCollection([
585
+ { operation: "update", roles: ["admin"], mode: "permissive" },
586
+ { operation: "update", ownerField: "user_id", mode: "permissive" }
587
+ ]);
588
+ const owned = createMockEntity({ user_id: "user-123" });
589
+ const notOwned = createMockEntity({ user_id: "other" });
590
+
591
+ expect(canEditEntity(collection, adminAuthController, "test", notOwned)).toBe(true);
592
+ expect(canEditEntity(collection, mockAuthController, "test", owned)).toBe(true);
593
+ expect(canEditEntity(collection, mockAuthController, "test", notOwned)).toBe(false);
594
+ });
595
+
596
+ test("54. 'all' rule permissive base + specific delete restrictive", () => {
597
+ const collection = createMockCollection([
598
+ { operation: "all", access: "public", mode: "permissive" },
599
+ { operation: "delete", mode: "restrictive", using: "is_deletable = 'true'" }
600
+ ]);
601
+ const deletable = createMockEntity({ is_deletable: "true" });
602
+ const notDeletable = createMockEntity({ is_deletable: "false" });
603
+
604
+ // Reading still works (only 'all' permissive applies to select)
605
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
606
+ // Deletable entity passes restrictive
607
+ expect(canDeleteEntity(collection, mockAuthController, "test", deletable)).toBe(true);
608
+ // Non-deletable entity fails restrictive
609
+ expect(canDeleteEntity(collection, mockAuthController, "test", notDeletable)).toBe(false);
610
+ });
611
+
612
+ test("55. Three-user-role scenario: platform role hierarchy", () => {
613
+ const editorUser: User = {
614
+ uid: "editor-1", email: "e@e.com", displayName: "Ed",
615
+ providerId: "test", isAnonymous: false, photoURL: null,
616
+ roles: ["editor"]
617
+ };
618
+ const editorAuth: AuthController<any> = { ...mockAuthController, user: editorUser };
619
+
620
+ const collection = createMockCollection([
621
+ { operation: "select", roles: ["admin", "editor", "author"] }, // read all three
622
+ { operation: "insert", roles: ["admin", "editor"] }, // create admin/editor
623
+ { operation: "update", roles: ["admin"] }, // edit admin only
624
+ { operation: "delete", roles: ["admin"] } // delete admin only
625
+ ]);
626
+
627
+ // Admin
628
+ expect(canReadCollection(collection, adminAuthController)).toBe(true);
629
+ expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
630
+ expect(canEditEntity(collection, adminAuthController, "test", null)).toBe(true);
631
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
632
+ // Editor
633
+ expect(canReadCollection(collection, editorAuth)).toBe(true);
634
+ expect(canCreateEntity(collection, editorAuth, "test", null)).toBe(true);
635
+ expect(canEditEntity(collection, editorAuth, "test", null)).toBe(false);
636
+ expect(canDeleteEntity(collection, editorAuth, "test", null)).toBe(false);
637
+ // Author (mockUser)
638
+ expect(canReadCollection(collection, mockAuthController)).toBe(true);
639
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(false);
640
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(false);
641
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
642
+ // Unauth
643
+ expect(canReadCollection(collection, unauthenticatedController)).toBe(false);
644
+ });
645
+
646
+ test("56. Publication workflow: draft/review/published state machine", () => {
647
+ const collection = createMockCollection([
648
+ // Anyone can read published content
649
+ { operation: "select", using: "status = 'published'" },
650
+ // Authors can only create drafts
651
+ { operation: "insert", roles: ["author"], withCheck: "status = 'draft'" },
652
+ // Authors can only edit their own draft or review content
653
+ { operation: "update", roles: ["author"], ownerField: "author_id", using: "status != 'published'" },
654
+ // Editors can move anything to/from review/published
655
+ { operation: "update", roles: ["editor"] },
656
+ // Only admins can delete
657
+ { operation: "delete", roles: ["admin"] }
658
+ ]);
659
+
660
+ const draft = createMockEntity({ status: "draft", author_id: "user-123" });
661
+ const published = createMockEntity({ status: "published", author_id: "user-123" });
662
+ const othersDraft = createMockEntity({ status: "draft", author_id: "someone-else" });
663
+
664
+ // Author can create (null entity, optimistic)
665
+ expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
666
+ // Author can edit their own draft
667
+ expect(canEditEntity(collection, mockAuthController, "test", draft)).toBe(true);
668
+ // Author CANNOT edit their own published content (status = 'published' fails using)
669
+ expect(canEditEntity(collection, mockAuthController, "test", published)).toBe(false);
670
+ // Author CANNOT edit others' drafts
671
+ expect(canEditEntity(collection, mockAuthController, "test", othersDraft)).toBe(false);
672
+ // Author CANNOT delete
673
+ expect(canDeleteEntity(collection, mockAuthController, "test", null)).toBe(false);
674
+ // Admin CAN delete
675
+ expect(canDeleteEntity(collection, adminAuthController, "test", null)).toBe(true);
676
+ });
677
+
678
+ test("57. Unauthenticated user: ownerField rule denies, SQL rule without roles still grants", () => {
679
+ // Three update rules, each independently permissive (OR logic).
680
+ // Rule 1: admin-only role → unauthenticated lacks it → filtered out
681
+ // Rule 2: ownerField user_id → entity.user_id = null, auth.uid = undefined → null !== undefined → fails
682
+ // Rule 3: using status = 'draft' → passes, has no role restriction → grants true
683
+ // Result: true (rule 3 wins)
684
+ const collection = createMockCollection([
685
+ { operation: "update", roles: ["admin"] },
686
+ { operation: "update", ownerField: "user_id" },
687
+ { operation: "update", using: "status = 'draft'" }
688
+ ]);
689
+ const draftOwned = createMockEntity({ user_id: null, status: "draft" });
690
+ expect(canEditEntity(collection, unauthenticatedController, "test", draftOwned)).toBe(true);
691
+
692
+ // To actually restrict unauthenticated users, the 3rd rule needs a role guard:
693
+ const restrictedCollection = createMockCollection([
694
+ { operation: "update", roles: ["admin"] },
695
+ { operation: "update", ownerField: "user_id" },
696
+ { operation: "update", roles: ["author"], using: "status = 'draft'" }
697
+ ]);
698
+ expect(canEditEntity(collection, unauthenticatedController, "test", draftOwned)).toBe(true);
699
+ expect(canEditEntity(restrictedCollection, unauthenticatedController, "test", draftOwned)).toBe(false);
700
+ });
701
+
702
+ test("58. Missing authController ('blankAuth') handled gracefully", () => {
703
+ const collection = createMockCollection([{ operation: "insert", roles: ["admin"] }]);
704
+ const blankAuth = { user: null } as any;
705
+ expect(canCreateEntity(collection, blankAuth, "test", null)).toBe(false);
706
+ });
707
+
708
+ test("59. SQL injection-like string in 'using' is safely treated as unparseable → optimistic", () => {
709
+ const collection = createMockCollection([
710
+ { operation: "update", using: "\"); DROP TABLE users; --" }
711
+ ]);
712
+ // Frontend never executes SQL, just parses. Malformed string → optimistic pass
713
+ expect(canEditEntity(collection, mockAuthController, "test", null)).toBe(true);
714
+ });
715
+ });
716
+ });