@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.
- package/README.md +4 -0
- package/dist/collections/CollectionRegistry.d.ts +1 -1
- package/dist/collections/default-collections.d.ts +15 -84
- package/dist/data/buildRebaseData.d.ts +1 -1
- package/dist/data/filter-dialect.d.ts +11 -0
- package/dist/data/sort-dialect.d.ts +15 -3
- package/dist/index.es.js +375 -63
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +69 -24
- package/dist/util/callback-errors.d.ts +77 -0
- package/dist/util/callback-errors.test.d.ts +1 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.d.ts +6 -0
- package/dist/util/relations.d.ts +41 -0
- package/dist/util/table-name.test.d.ts +1 -0
- package/package.json +26 -22
- package/src/collections/CollectionRegistry.ts +0 -485
- package/src/collections/default-collections.ts +0 -109
- package/src/collections/index.ts +0 -2
- package/src/data/buildRebaseData.ts +0 -816
- package/src/data/buildRoutedRebaseData.ts +0 -103
- package/src/data/filter-conditions.ts +0 -46
- package/src/data/filter-dialect.ts +0 -737
- package/src/data/paginate.ts +0 -334
- package/src/data/query_builder.ts +0 -176
- package/src/data/resolveDataSource.ts +0 -135
- package/src/data/sort-dialect.ts +0 -237
- package/src/index.ts +0 -11
- package/src/table-classification.ts +0 -109
- package/src/types/json-logic-js.d.ts +0 -8
- package/src/util/auth-default-policies.ts +0 -215
- package/src/util/builders.ts +0 -82
- package/src/util/callbacks.ts +0 -122
- package/src/util/collections.ts +0 -117
- package/src/util/common.ts +0 -2
- package/src/util/conditions.ts +0 -168
- package/src/util/email.ts +0 -32
- package/src/util/entities.ts +0 -282
- package/src/util/enums.ts +0 -26
- package/src/util/identity.ts +0 -202
- package/src/util/index.ts +0 -21
- package/src/util/internal-tables.test.ts +0 -188
- package/src/util/internal-tables.ts +0 -197
- package/src/util/junction-policies.ts +0 -355
- package/src/util/paths.ts +0 -27
- package/src/util/permissions.test.ts +0 -866
- package/src/util/permissions.ts +0 -206
- package/src/util/pg-column-to-property.ts +0 -377
- package/src/util/policy/evaluatePolicy.ts +0 -194
- package/src/util/policy/index.ts +0 -4
- package/src/util/policy/policyToPostgres.ts +0 -263
- package/src/util/policy/securityRuleToConditions.ts +0 -67
- package/src/util/policy/sqlToPolicy.ts +0 -422
- package/src/util/relations.ts +0 -236
- package/src/util/resolutions.ts +0 -534
- package/src/util/resolve-relation.ts +0 -243
- package/src/util/storage.ts +0 -177
- 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
|
-
});
|