@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.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/collections/CollectionRegistry.d.ts +48 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +2380 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +2379 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/util/arrays.d.ts +1 -0
- package/dist/util/builders.d.ts +64 -0
- package/dist/util/callbacks.d.ts +6 -0
- package/dist/util/collections.d.ts +11 -0
- package/dist/util/common.d.ts +2 -0
- package/dist/util/conditions.d.ts +26 -0
- package/dist/util/dates.d.ts +1 -0
- package/dist/util/entities.d.ts +28 -0
- package/dist/util/entity_actions.d.ts +2 -0
- package/dist/util/enums.d.ts +3 -0
- package/dist/util/fields.d.ts +2 -0
- package/dist/util/flatten_object.d.ts +5 -0
- package/dist/util/hash.d.ts +1 -0
- package/dist/util/index.d.ts +26 -0
- package/dist/util/names.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +29 -0
- package/dist/util/navigation_utils.d.ts +31 -0
- package/dist/util/objects.d.ts +26 -0
- package/dist/util/os.d.ts +2 -0
- package/dist/util/parent_references_from_path.d.ts +6 -0
- package/dist/util/paths.d.ts +14 -0
- package/dist/util/permissions.d.ts +5 -0
- package/dist/util/permissions.test.d.ts +1 -0
- package/dist/util/plurals.d.ts +16 -0
- package/dist/util/references.d.ts +2 -0
- package/dist/util/regexp.d.ts +7 -0
- package/dist/util/relations.d.ts +12 -0
- package/dist/util/resolutions.d.ts +74 -0
- package/dist/util/storage.d.ts +24 -0
- package/dist/util/strings.d.ts +7 -0
- package/package.json +118 -0
- package/src/collections/CollectionRegistry.ts +319 -0
- package/src/collections/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/util/arrays.ts +3 -0
- package/src/util/builders.ts +138 -0
- package/src/util/callbacks.ts +115 -0
- package/src/util/collections.ts +126 -0
- package/src/util/common.ts +2 -0
- package/src/util/conditions.ts +348 -0
- package/src/util/dates.ts +1 -0
- package/src/util/entities.ts +212 -0
- package/src/util/entity_actions.ts +28 -0
- package/src/util/enums.ts +26 -0
- package/src/util/fields.ts +28 -0
- package/src/util/flatten_object.ts +45 -0
- package/src/util/hash.ts +11 -0
- package/src/util/index.ts +26 -0
- package/src/util/names.ts +30 -0
- package/src/util/navigation_from_path.ts +121 -0
- package/src/util/navigation_utils.ts +222 -0
- package/src/util/objects.ts +376 -0
- package/src/util/os.ts +13 -0
- package/src/util/parent_references_from_path.ts +57 -0
- package/src/util/paths.ts +27 -0
- package/src/util/permissions.test.ts +716 -0
- package/src/util/permissions.ts +235 -0
- package/src/util/plurals.ts +188 -0
- package/src/util/references.ts +34 -0
- package/src/util/regexp.ts +32 -0
- package/src/util/relations.ts +211 -0
- package/src/util/resolutions.ts +383 -0
- package/src/util/storage.ts +144 -0
- 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
|
+
});
|