@pyreon/permissions 0.11.5 → 0.11.7
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 +22 -18
- package/lib/index.js.map +1 -1
- package/package.json +16 -16
- package/src/context.ts +4 -4
- package/src/index.ts +3 -8
- package/src/permissions.ts +6 -6
- package/src/tests/api.test.ts +156 -156
- package/src/tests/context.test.ts +25 -25
- package/src/tests/permissions.test.ts +284 -284
- package/src/tests/predicates.test.ts +74 -74
- package/src/tests/wildcards.test.ts +80 -80
- package/src/types.ts +1 -1
|
@@ -1,197 +1,197 @@
|
|
|
1
|
-
import { computed, effect, signal } from
|
|
2
|
-
import { describe, expect, it } from
|
|
3
|
-
import { createPermissions } from
|
|
1
|
+
import { computed, effect, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createPermissions } from '../index'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
5
|
+
describe('createPermissions', () => {
|
|
6
6
|
// ─── Basic checks ────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
describe(
|
|
9
|
-
it(
|
|
10
|
-
const can = createPermissions({
|
|
11
|
-
expect(can(
|
|
8
|
+
describe('static permissions', () => {
|
|
9
|
+
it('returns true for granted permissions', () => {
|
|
10
|
+
const can = createPermissions({ 'posts.read': true })
|
|
11
|
+
expect(can('posts.read')).toBe(true)
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
-
it(
|
|
15
|
-
const can = createPermissions({
|
|
16
|
-
expect(can(
|
|
14
|
+
it('returns false for denied permissions', () => {
|
|
15
|
+
const can = createPermissions({ 'posts.delete': false })
|
|
16
|
+
expect(can('posts.delete')).toBe(false)
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
it(
|
|
20
|
-
const can = createPermissions({
|
|
21
|
-
expect(can(
|
|
19
|
+
it('returns false for undefined permissions', () => {
|
|
20
|
+
const can = createPermissions({ 'posts.read': true })
|
|
21
|
+
expect(can('users.manage')).toBe(false)
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
it(
|
|
24
|
+
it('handles empty initial permissions', () => {
|
|
25
25
|
const can = createPermissions()
|
|
26
|
-
expect(can(
|
|
26
|
+
expect(can('anything')).toBe(false)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
it(
|
|
29
|
+
it('handles empty object', () => {
|
|
30
30
|
const can = createPermissions({})
|
|
31
|
-
expect(can(
|
|
31
|
+
expect(can('anything')).toBe(false)
|
|
32
32
|
})
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
// ─── Predicate permissions ─────────────────────────────────────────────
|
|
36
36
|
|
|
37
|
-
describe(
|
|
38
|
-
it(
|
|
39
|
-
const role = signal(
|
|
37
|
+
describe('predicate permissions', () => {
|
|
38
|
+
it('evaluates predicate without context', () => {
|
|
39
|
+
const role = signal('admin')
|
|
40
40
|
const can = createPermissions({
|
|
41
|
-
|
|
41
|
+
'users.manage': () => role() === 'admin',
|
|
42
42
|
})
|
|
43
|
-
expect(can(
|
|
43
|
+
expect(can('users.manage')).toBe(true)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
it(
|
|
47
|
-
const userId = signal(
|
|
46
|
+
it('evaluates predicate with context', () => {
|
|
47
|
+
const userId = signal('user-1')
|
|
48
48
|
const can = createPermissions({
|
|
49
|
-
|
|
49
|
+
'posts.update': (post: any) => post?.authorId === userId.peek(),
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
expect(can(
|
|
53
|
-
expect(can(
|
|
52
|
+
expect(can('posts.update', { authorId: 'user-1' })).toBe(true)
|
|
53
|
+
expect(can('posts.update', { authorId: 'user-2' })).toBe(false)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it(
|
|
56
|
+
it('predicate without context returns result of calling with undefined', () => {
|
|
57
57
|
const can = createPermissions({
|
|
58
|
-
|
|
58
|
+
'posts.update': (post?: any) => post?.authorId === 'user-1',
|
|
59
59
|
})
|
|
60
60
|
// No context — post is undefined, so authorId check fails
|
|
61
|
-
expect(can(
|
|
61
|
+
expect(can('posts.update')).toBe(false)
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
-
it(
|
|
64
|
+
it('predicate that ignores context', () => {
|
|
65
65
|
const can = createPermissions({
|
|
66
|
-
|
|
66
|
+
'posts.create': () => true,
|
|
67
67
|
})
|
|
68
|
-
expect(can(
|
|
68
|
+
expect(can('posts.create')).toBe(true)
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
// ─── Wildcard matching ─────────────────────────────────────────────────
|
|
73
73
|
|
|
74
|
-
describe(
|
|
75
|
-
it(
|
|
76
|
-
const can = createPermissions({
|
|
77
|
-
expect(can(
|
|
78
|
-
expect(can(
|
|
79
|
-
expect(can(
|
|
74
|
+
describe('wildcard matching', () => {
|
|
75
|
+
it('matches prefix wildcard', () => {
|
|
76
|
+
const can = createPermissions({ 'posts.*': true })
|
|
77
|
+
expect(can('posts.read')).toBe(true)
|
|
78
|
+
expect(can('posts.create')).toBe(true)
|
|
79
|
+
expect(can('posts.delete')).toBe(true)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
it(
|
|
82
|
+
it('exact match takes precedence over wildcard', () => {
|
|
83
83
|
const can = createPermissions({
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
'posts.*': true,
|
|
85
|
+
'posts.delete': false,
|
|
86
86
|
})
|
|
87
|
-
expect(can(
|
|
88
|
-
expect(can(
|
|
87
|
+
expect(can('posts.read')).toBe(true)
|
|
88
|
+
expect(can('posts.delete')).toBe(false)
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
it(
|
|
92
|
-
const can = createPermissions({
|
|
93
|
-
expect(can(
|
|
94
|
-
expect(can(
|
|
95
|
-
expect(can(
|
|
91
|
+
it('global wildcard matches everything', () => {
|
|
92
|
+
const can = createPermissions({ '*': true })
|
|
93
|
+
expect(can('posts.read')).toBe(true)
|
|
94
|
+
expect(can('users.manage')).toBe(true)
|
|
95
|
+
expect(can('anything')).toBe(true)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
-
it(
|
|
98
|
+
it('exact match takes precedence over global wildcard', () => {
|
|
99
99
|
const can = createPermissions({
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
'*': true,
|
|
101
|
+
'billing.export': false,
|
|
102
102
|
})
|
|
103
|
-
expect(can(
|
|
104
|
-
expect(can(
|
|
103
|
+
expect(can('posts.read')).toBe(true)
|
|
104
|
+
expect(can('billing.export')).toBe(false)
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
it(
|
|
107
|
+
it('prefix wildcard takes precedence over global wildcard', () => {
|
|
108
108
|
const can = createPermissions({
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
'*': false,
|
|
110
|
+
'posts.*': true,
|
|
111
111
|
})
|
|
112
|
-
expect(can(
|
|
113
|
-
expect(can(
|
|
112
|
+
expect(can('posts.read')).toBe(true)
|
|
113
|
+
expect(can('users.manage')).toBe(false)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
it(
|
|
116
|
+
it('wildcard with predicate', () => {
|
|
117
117
|
const can = createPermissions({
|
|
118
|
-
|
|
118
|
+
'posts.*': (post: any) => post?.status !== 'archived',
|
|
119
119
|
})
|
|
120
|
-
expect(can(
|
|
121
|
-
expect(can(
|
|
120
|
+
expect(can('posts.read', { status: 'published' })).toBe(true)
|
|
121
|
+
expect(can('posts.update', { status: 'archived' })).toBe(false)
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
it(
|
|
125
|
-
const can = createPermissions({
|
|
126
|
-
expect(can(
|
|
124
|
+
it('does not match partial prefixes', () => {
|
|
125
|
+
const can = createPermissions({ 'post.*': true })
|
|
126
|
+
expect(can('posts.read')).toBe(false) // 'posts' !== 'post'
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
// ─── can.not ───────────────────────────────────────────────────────────
|
|
131
131
|
|
|
132
|
-
describe(
|
|
133
|
-
it(
|
|
132
|
+
describe('can.not()', () => {
|
|
133
|
+
it('returns inverse of can()', () => {
|
|
134
134
|
const can = createPermissions({
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
'posts.read': true,
|
|
136
|
+
'posts.delete': false,
|
|
137
137
|
})
|
|
138
|
-
expect(can.not(
|
|
139
|
-
expect(can.not(
|
|
140
|
-
expect(can.not(
|
|
138
|
+
expect(can.not('posts.read')).toBe(false)
|
|
139
|
+
expect(can.not('posts.delete')).toBe(true)
|
|
140
|
+
expect(can.not('users.manage')).toBe(true)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
it(
|
|
143
|
+
it('works with predicates and context', () => {
|
|
144
144
|
const can = createPermissions({
|
|
145
|
-
|
|
145
|
+
'posts.update': (post: any) => post?.authorId === 'me',
|
|
146
146
|
})
|
|
147
|
-
expect(can.not(
|
|
148
|
-
expect(can.not(
|
|
147
|
+
expect(can.not('posts.update', { authorId: 'me' })).toBe(false)
|
|
148
|
+
expect(can.not('posts.update', { authorId: 'other' })).toBe(true)
|
|
149
149
|
})
|
|
150
150
|
})
|
|
151
151
|
|
|
152
152
|
// ─── can.all / can.any ─────────────────────────────────────────────────
|
|
153
153
|
|
|
154
|
-
describe(
|
|
155
|
-
it(
|
|
154
|
+
describe('can.all()', () => {
|
|
155
|
+
it('returns true when all permissions are granted', () => {
|
|
156
156
|
const can = createPermissions({
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
'posts.read': true,
|
|
158
|
+
'posts.create': true,
|
|
159
159
|
})
|
|
160
|
-
expect(can.all(
|
|
160
|
+
expect(can.all('posts.read', 'posts.create')).toBe(true)
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
-
it(
|
|
163
|
+
it('returns false when any permission is denied', () => {
|
|
164
164
|
const can = createPermissions({
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
'posts.read': true,
|
|
166
|
+
'posts.delete': false,
|
|
167
167
|
})
|
|
168
|
-
expect(can.all(
|
|
168
|
+
expect(can.all('posts.read', 'posts.delete')).toBe(false)
|
|
169
169
|
})
|
|
170
170
|
|
|
171
|
-
it(
|
|
171
|
+
it('returns true for empty args', () => {
|
|
172
172
|
const can = createPermissions()
|
|
173
173
|
expect(can.all()).toBe(true)
|
|
174
174
|
})
|
|
175
175
|
})
|
|
176
176
|
|
|
177
|
-
describe(
|
|
178
|
-
it(
|
|
177
|
+
describe('can.any()', () => {
|
|
178
|
+
it('returns true when any permission is granted', () => {
|
|
179
179
|
const can = createPermissions({
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
'posts.read': false,
|
|
181
|
+
'posts.create': true,
|
|
182
182
|
})
|
|
183
|
-
expect(can.any(
|
|
183
|
+
expect(can.any('posts.read', 'posts.create')).toBe(true)
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
it(
|
|
186
|
+
it('returns false when no permissions are granted', () => {
|
|
187
187
|
const can = createPermissions({
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
'posts.read': false,
|
|
189
|
+
'posts.delete': false,
|
|
190
190
|
})
|
|
191
|
-
expect(can.any(
|
|
191
|
+
expect(can.any('posts.read', 'posts.delete')).toBe(false)
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
-
it(
|
|
194
|
+
it('returns false for empty args', () => {
|
|
195
195
|
const can = createPermissions()
|
|
196
196
|
expect(can.any()).toBe(false)
|
|
197
197
|
})
|
|
@@ -199,83 +199,83 @@ describe("createPermissions", () => {
|
|
|
199
199
|
|
|
200
200
|
// ─── set / patch ───────────────────────────────────────────────────────
|
|
201
201
|
|
|
202
|
-
describe(
|
|
203
|
-
it(
|
|
202
|
+
describe('set()', () => {
|
|
203
|
+
it('replaces all permissions', () => {
|
|
204
204
|
const can = createPermissions({
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
'posts.read': true,
|
|
206
|
+
'users.manage': true,
|
|
207
207
|
})
|
|
208
|
-
expect(can(
|
|
209
|
-
expect(can(
|
|
208
|
+
expect(can('posts.read')).toBe(true)
|
|
209
|
+
expect(can('users.manage')).toBe(true)
|
|
210
210
|
|
|
211
|
-
can.set({
|
|
212
|
-
expect(can(
|
|
213
|
-
expect(can(
|
|
211
|
+
can.set({ 'posts.read': false })
|
|
212
|
+
expect(can('posts.read')).toBe(false)
|
|
213
|
+
expect(can('users.manage')).toBe(false) // replaced, not merged
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
-
it(
|
|
217
|
-
const can = createPermissions({
|
|
216
|
+
it('triggers reactive updates', () => {
|
|
217
|
+
const can = createPermissions({ 'posts.read': true })
|
|
218
218
|
const results: boolean[] = []
|
|
219
219
|
|
|
220
220
|
effect(() => {
|
|
221
|
-
results.push(can(
|
|
221
|
+
results.push(can('posts.read'))
|
|
222
222
|
})
|
|
223
223
|
|
|
224
224
|
expect(results).toEqual([true])
|
|
225
225
|
|
|
226
|
-
can.set({
|
|
226
|
+
can.set({ 'posts.read': false })
|
|
227
227
|
expect(results).toEqual([true, false])
|
|
228
228
|
})
|
|
229
229
|
})
|
|
230
230
|
|
|
231
|
-
describe(
|
|
232
|
-
it(
|
|
231
|
+
describe('patch()', () => {
|
|
232
|
+
it('merges with existing permissions', () => {
|
|
233
233
|
const can = createPermissions({
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
'posts.read': true,
|
|
235
|
+
'users.manage': false,
|
|
236
236
|
})
|
|
237
237
|
|
|
238
|
-
can.patch({
|
|
239
|
-
expect(can(
|
|
240
|
-
expect(can(
|
|
241
|
-
expect(can(
|
|
238
|
+
can.patch({ 'users.manage': true, 'billing.view': true })
|
|
239
|
+
expect(can('posts.read')).toBe(true) // unchanged
|
|
240
|
+
expect(can('users.manage')).toBe(true) // updated
|
|
241
|
+
expect(can('billing.view')).toBe(true) // added
|
|
242
242
|
})
|
|
243
243
|
|
|
244
|
-
it(
|
|
245
|
-
const can = createPermissions({
|
|
244
|
+
it('triggers reactive updates', () => {
|
|
245
|
+
const can = createPermissions({ 'posts.read': false })
|
|
246
246
|
const results: boolean[] = []
|
|
247
247
|
|
|
248
248
|
effect(() => {
|
|
249
|
-
results.push(can(
|
|
249
|
+
results.push(can('posts.read'))
|
|
250
250
|
})
|
|
251
251
|
|
|
252
252
|
expect(results).toEqual([false])
|
|
253
253
|
|
|
254
|
-
can.patch({
|
|
254
|
+
can.patch({ 'posts.read': true })
|
|
255
255
|
expect(results).toEqual([false, true])
|
|
256
256
|
})
|
|
257
257
|
})
|
|
258
258
|
|
|
259
259
|
// ─── Reactivity ────────────────────────────────────────────────────────
|
|
260
260
|
|
|
261
|
-
describe(
|
|
262
|
-
it(
|
|
263
|
-
const can = createPermissions({
|
|
261
|
+
describe('reactivity', () => {
|
|
262
|
+
it('can() is reactive inside effect', () => {
|
|
263
|
+
const can = createPermissions({ 'posts.read': true })
|
|
264
264
|
const results: boolean[] = []
|
|
265
265
|
|
|
266
266
|
effect(() => {
|
|
267
|
-
results.push(can(
|
|
267
|
+
results.push(can('posts.read'))
|
|
268
268
|
})
|
|
269
269
|
|
|
270
|
-
can.set({
|
|
271
|
-
can.set({
|
|
270
|
+
can.set({ 'posts.read': false })
|
|
271
|
+
can.set({ 'posts.read': true })
|
|
272
272
|
|
|
273
273
|
expect(results).toEqual([true, false, true])
|
|
274
274
|
})
|
|
275
275
|
|
|
276
|
-
it(
|
|
276
|
+
it('can() is reactive inside computed', () => {
|
|
277
277
|
const can = createPermissions({ admin: true })
|
|
278
|
-
const isAdmin = computed(() => can(
|
|
278
|
+
const isAdmin = computed(() => can('admin'))
|
|
279
279
|
|
|
280
280
|
expect(isAdmin()).toBe(true)
|
|
281
281
|
|
|
@@ -283,61 +283,61 @@ describe("createPermissions", () => {
|
|
|
283
283
|
expect(isAdmin()).toBe(false)
|
|
284
284
|
})
|
|
285
285
|
|
|
286
|
-
it(
|
|
287
|
-
const can = createPermissions({
|
|
286
|
+
it('can.not() is reactive', () => {
|
|
287
|
+
const can = createPermissions({ 'posts.read': true })
|
|
288
288
|
const results: boolean[] = []
|
|
289
289
|
|
|
290
290
|
effect(() => {
|
|
291
|
-
results.push(can.not(
|
|
291
|
+
results.push(can.not('posts.read'))
|
|
292
292
|
})
|
|
293
293
|
|
|
294
|
-
can.set({
|
|
294
|
+
can.set({ 'posts.read': false })
|
|
295
295
|
expect(results).toEqual([false, true])
|
|
296
296
|
})
|
|
297
297
|
|
|
298
|
-
it(
|
|
298
|
+
it('can.all() is reactive', () => {
|
|
299
299
|
const can = createPermissions({
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
'posts.read': true,
|
|
301
|
+
'posts.create': true,
|
|
302
302
|
})
|
|
303
303
|
const results: boolean[] = []
|
|
304
304
|
|
|
305
305
|
effect(() => {
|
|
306
|
-
results.push(can.all(
|
|
306
|
+
results.push(can.all('posts.read', 'posts.create'))
|
|
307
307
|
})
|
|
308
308
|
|
|
309
|
-
can.patch({
|
|
309
|
+
can.patch({ 'posts.create': false })
|
|
310
310
|
expect(results).toEqual([true, false])
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
-
it(
|
|
313
|
+
it('can.any() is reactive', () => {
|
|
314
314
|
const can = createPermissions({
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
'posts.read': false,
|
|
316
|
+
'posts.create': true,
|
|
317
317
|
})
|
|
318
318
|
const results: boolean[] = []
|
|
319
319
|
|
|
320
320
|
effect(() => {
|
|
321
|
-
results.push(can.any(
|
|
321
|
+
results.push(can.any('posts.read', 'posts.create'))
|
|
322
322
|
})
|
|
323
323
|
|
|
324
|
-
can.patch({
|
|
324
|
+
can.patch({ 'posts.create': false })
|
|
325
325
|
expect(results).toEqual([true, false])
|
|
326
326
|
})
|
|
327
327
|
|
|
328
|
-
it(
|
|
329
|
-
const role = signal(
|
|
328
|
+
it('predicate with reactive signals inside', () => {
|
|
329
|
+
const role = signal('admin')
|
|
330
330
|
const can = createPermissions({
|
|
331
|
-
|
|
331
|
+
'users.manage': () => role() === 'admin',
|
|
332
332
|
})
|
|
333
333
|
|
|
334
334
|
// The predicate reads `role()` but reactivity is driven by
|
|
335
335
|
// the permission version signal, not the inner signal.
|
|
336
336
|
// To react to role changes, update permissions:
|
|
337
|
-
expect(can(
|
|
337
|
+
expect(can('users.manage')).toBe(true)
|
|
338
338
|
|
|
339
|
-
can.patch({
|
|
340
|
-
role.set(
|
|
339
|
+
can.patch({ 'users.manage': () => role() === 'admin' })
|
|
340
|
+
role.set('viewer')
|
|
341
341
|
// Need to re-evaluate — the predicate itself reads the signal
|
|
342
342
|
// but the permission system doesn't track inner signal deps.
|
|
343
343
|
// This is by design: update permissions via set/patch when the source changes.
|
|
@@ -346,55 +346,55 @@ describe("createPermissions", () => {
|
|
|
346
346
|
|
|
347
347
|
// ─── granted / entries ─────────────────────────────────────────────────
|
|
348
348
|
|
|
349
|
-
describe(
|
|
350
|
-
it(
|
|
349
|
+
describe('granted()', () => {
|
|
350
|
+
it('returns keys with true values', () => {
|
|
351
351
|
const can = createPermissions({
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
352
|
+
'posts.read': true,
|
|
353
|
+
'posts.delete': false,
|
|
354
|
+
'users.manage': true,
|
|
355
355
|
})
|
|
356
|
-
expect(can.granted()).toEqual(expect.arrayContaining([
|
|
357
|
-
expect(can.granted()).not.toContain(
|
|
356
|
+
expect(can.granted()).toEqual(expect.arrayContaining(['posts.read', 'users.manage']))
|
|
357
|
+
expect(can.granted()).not.toContain('posts.delete')
|
|
358
358
|
})
|
|
359
359
|
|
|
360
|
-
it(
|
|
360
|
+
it('includes predicate keys', () => {
|
|
361
361
|
const can = createPermissions({
|
|
362
|
-
|
|
362
|
+
'posts.update': (post: any) => post?.authorId === 'me',
|
|
363
363
|
})
|
|
364
364
|
// Predicates are capabilities — they exist
|
|
365
|
-
expect(can.granted()).toContain(
|
|
365
|
+
expect(can.granted()).toContain('posts.update')
|
|
366
366
|
})
|
|
367
367
|
|
|
368
|
-
it(
|
|
369
|
-
const can = createPermissions({
|
|
368
|
+
it('is reactive', () => {
|
|
369
|
+
const can = createPermissions({ 'posts.read': true })
|
|
370
370
|
const results: string[][] = []
|
|
371
371
|
|
|
372
372
|
effect(() => {
|
|
373
373
|
results.push([...can.granted()])
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
-
can.patch({
|
|
377
|
-
expect(results).toEqual([[
|
|
376
|
+
can.patch({ 'users.manage': true })
|
|
377
|
+
expect(results).toEqual([['posts.read'], ['posts.read', 'users.manage']])
|
|
378
378
|
})
|
|
379
379
|
})
|
|
380
380
|
|
|
381
|
-
describe(
|
|
382
|
-
it(
|
|
381
|
+
describe('entries()', () => {
|
|
382
|
+
it('returns all entries', () => {
|
|
383
383
|
const can = createPermissions({
|
|
384
|
-
|
|
385
|
-
|
|
384
|
+
'posts.read': true,
|
|
385
|
+
'posts.delete': false,
|
|
386
386
|
})
|
|
387
387
|
const entries = can.entries()
|
|
388
388
|
expect(entries).toHaveLength(2)
|
|
389
389
|
expect(entries).toEqual(
|
|
390
390
|
expect.arrayContaining([
|
|
391
|
-
[
|
|
392
|
-
[
|
|
391
|
+
['posts.read', true],
|
|
392
|
+
['posts.delete', false],
|
|
393
393
|
]),
|
|
394
394
|
)
|
|
395
395
|
})
|
|
396
396
|
|
|
397
|
-
it(
|
|
397
|
+
it('is reactive', () => {
|
|
398
398
|
const can = createPermissions({ a: true })
|
|
399
399
|
const counts: number[] = []
|
|
400
400
|
|
|
@@ -409,50 +409,50 @@ describe("createPermissions", () => {
|
|
|
409
409
|
|
|
410
410
|
// ─── Error handling & edge cases ──────────────────────────────────────
|
|
411
411
|
|
|
412
|
-
describe(
|
|
413
|
-
it(
|
|
412
|
+
describe('predicate that throws', () => {
|
|
413
|
+
it('returns false when predicate throws', () => {
|
|
414
414
|
const can = createPermissions({
|
|
415
|
-
|
|
416
|
-
throw new Error(
|
|
415
|
+
'posts.update': () => {
|
|
416
|
+
throw new Error('predicate boom')
|
|
417
417
|
},
|
|
418
418
|
})
|
|
419
419
|
// Should not crash — returns false
|
|
420
|
-
expect(can(
|
|
420
|
+
expect(can('posts.update')).toBe(false)
|
|
421
421
|
})
|
|
422
422
|
|
|
423
|
-
it(
|
|
423
|
+
it('returns false with context when predicate throws', () => {
|
|
424
424
|
const can = createPermissions({
|
|
425
|
-
|
|
426
|
-
throw new Error(
|
|
425
|
+
'posts.update': (_post: any) => {
|
|
426
|
+
throw new Error('predicate boom')
|
|
427
427
|
},
|
|
428
428
|
})
|
|
429
|
-
expect(can(
|
|
429
|
+
expect(can('posts.update', { id: 1 })).toBe(false)
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
it(
|
|
432
|
+
it('can.not returns true when predicate throws', () => {
|
|
433
433
|
const can = createPermissions({
|
|
434
|
-
|
|
435
|
-
throw new Error(
|
|
434
|
+
'posts.update': () => {
|
|
435
|
+
throw new Error('boom')
|
|
436
436
|
},
|
|
437
437
|
})
|
|
438
|
-
expect(can.not(
|
|
438
|
+
expect(can.not('posts.update')).toBe(true)
|
|
439
439
|
})
|
|
440
440
|
})
|
|
441
441
|
|
|
442
|
-
describe(
|
|
443
|
-
it(
|
|
444
|
-
const can = createPermissions({
|
|
442
|
+
describe('can.all/can.any with empty args', () => {
|
|
443
|
+
it('can.all() with no args returns true (vacuous truth)', () => {
|
|
444
|
+
const can = createPermissions({ 'posts.read': false })
|
|
445
445
|
expect(can.all()).toBe(true)
|
|
446
446
|
})
|
|
447
447
|
|
|
448
|
-
it(
|
|
449
|
-
const can = createPermissions({
|
|
448
|
+
it('can.any() with no args returns false', () => {
|
|
449
|
+
const can = createPermissions({ 'posts.read': true })
|
|
450
450
|
expect(can.any()).toBe(false)
|
|
451
451
|
})
|
|
452
452
|
})
|
|
453
453
|
|
|
454
|
-
describe(
|
|
455
|
-
it(
|
|
454
|
+
describe('rapid set/patch cycles', () => {
|
|
455
|
+
it('final state is correct after rapid set calls', () => {
|
|
456
456
|
const can = createPermissions({ a: true })
|
|
457
457
|
|
|
458
458
|
for (let i = 0; i < 50; i++) {
|
|
@@ -460,10 +460,10 @@ describe("createPermissions", () => {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
// 50 iterations, last i=49, 49 % 2 = 1, so false
|
|
463
|
-
expect(can(
|
|
463
|
+
expect(can('a')).toBe(false)
|
|
464
464
|
})
|
|
465
465
|
|
|
466
|
-
it(
|
|
466
|
+
it('final state is correct after rapid patch calls', () => {
|
|
467
467
|
const can = createPermissions({ a: true, b: false })
|
|
468
468
|
|
|
469
469
|
for (let i = 0; i < 50; i++) {
|
|
@@ -471,16 +471,16 @@ describe("createPermissions", () => {
|
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
// last i=49: 49%2=1 → false, 49%3=1 → false
|
|
474
|
-
expect(can(
|
|
475
|
-
expect(can(
|
|
474
|
+
expect(can('a')).toBe(false)
|
|
475
|
+
expect(can('b')).toBe(false)
|
|
476
476
|
})
|
|
477
477
|
|
|
478
|
-
it(
|
|
478
|
+
it('reactive effects see correct final state after rapid changes', () => {
|
|
479
479
|
const can = createPermissions({ a: true })
|
|
480
480
|
const results: boolean[] = []
|
|
481
481
|
|
|
482
482
|
effect(() => {
|
|
483
|
-
results.push(can(
|
|
483
|
+
results.push(can('a'))
|
|
484
484
|
})
|
|
485
485
|
|
|
486
486
|
can.set({ a: false })
|
|
@@ -492,7 +492,7 @@ describe("createPermissions", () => {
|
|
|
492
492
|
expect(results[results.length - 1]).toBe(false)
|
|
493
493
|
})
|
|
494
494
|
|
|
495
|
-
it(
|
|
495
|
+
it('interleaved set and patch produce correct state', () => {
|
|
496
496
|
const can = createPermissions({})
|
|
497
497
|
|
|
498
498
|
can.set({ a: true, b: true })
|
|
@@ -500,15 +500,15 @@ describe("createPermissions", () => {
|
|
|
500
500
|
can.set({ a: false }) // replaces everything
|
|
501
501
|
can.patch({ d: true })
|
|
502
502
|
|
|
503
|
-
expect(can(
|
|
504
|
-
expect(can(
|
|
505
|
-
expect(can(
|
|
506
|
-
expect(can(
|
|
503
|
+
expect(can('a')).toBe(false)
|
|
504
|
+
expect(can('b')).toBe(false) // cleared by set
|
|
505
|
+
expect(can('c')).toBe(false) // cleared by set
|
|
506
|
+
expect(can('d')).toBe(true) // added by last patch
|
|
507
507
|
})
|
|
508
508
|
})
|
|
509
509
|
|
|
510
|
-
describe(
|
|
511
|
-
it(
|
|
510
|
+
describe('cleanup / disposal', () => {
|
|
511
|
+
it('granted computed still works after many set/patch cycles', () => {
|
|
512
512
|
const can = createPermissions({ a: true })
|
|
513
513
|
|
|
514
514
|
for (let i = 0; i < 20; i++) {
|
|
@@ -516,12 +516,12 @@ describe("createPermissions", () => {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
const granted = can.granted()
|
|
519
|
-
expect(granted).toContain(
|
|
520
|
-
expect(granted).toContain(
|
|
521
|
-
expect(granted).not.toContain(
|
|
519
|
+
expect(granted).toContain('a')
|
|
520
|
+
expect(granted).toContain('key-19')
|
|
521
|
+
expect(granted).not.toContain('key-0') // cleared by last set
|
|
522
522
|
})
|
|
523
523
|
|
|
524
|
-
it(
|
|
524
|
+
it('entries computed reflects current state after many mutations', () => {
|
|
525
525
|
const can = createPermissions({})
|
|
526
526
|
|
|
527
527
|
can.set({ a: true })
|
|
@@ -529,15 +529,15 @@ describe("createPermissions", () => {
|
|
|
529
529
|
can.set({ c: true })
|
|
530
530
|
|
|
531
531
|
const entries = can.entries()
|
|
532
|
-
expect(entries).toEqual([[
|
|
532
|
+
expect(entries).toEqual([['c', true]])
|
|
533
533
|
})
|
|
534
534
|
|
|
535
|
-
it(
|
|
535
|
+
it('effects tracking permissions react correctly to updates', () => {
|
|
536
536
|
const can = createPermissions({ a: true })
|
|
537
537
|
let count = 0
|
|
538
538
|
|
|
539
539
|
effect(() => {
|
|
540
|
-
can(
|
|
540
|
+
can('a')
|
|
541
541
|
count++
|
|
542
542
|
})
|
|
543
543
|
|
|
@@ -549,135 +549,135 @@ describe("createPermissions", () => {
|
|
|
549
549
|
|
|
550
550
|
// ─── Real-world patterns ───────────────────────────────────────────────
|
|
551
551
|
|
|
552
|
-
describe(
|
|
553
|
-
it(
|
|
552
|
+
describe('real-world patterns', () => {
|
|
553
|
+
it('role-based access control', () => {
|
|
554
554
|
function fromRole(role: string) {
|
|
555
555
|
const roles: Record<string, Record<string, boolean>> = {
|
|
556
|
-
admin: {
|
|
556
|
+
admin: { 'posts.*': true, 'users.*': true, 'billing.*': true },
|
|
557
557
|
editor: {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
558
|
+
'posts.read': true,
|
|
559
|
+
'posts.create': true,
|
|
560
|
+
'posts.update': true,
|
|
561
|
+
'users.read': true,
|
|
562
562
|
},
|
|
563
|
-
viewer: {
|
|
563
|
+
viewer: { 'posts.read': true },
|
|
564
564
|
}
|
|
565
565
|
return roles[role] ?? {}
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
-
const can = createPermissions(fromRole(
|
|
568
|
+
const can = createPermissions(fromRole('editor'))
|
|
569
569
|
|
|
570
|
-
expect(can(
|
|
571
|
-
expect(can(
|
|
572
|
-
expect(can(
|
|
573
|
-
expect(can(
|
|
574
|
-
expect(can(
|
|
575
|
-
expect(can(
|
|
570
|
+
expect(can('posts.read')).toBe(true)
|
|
571
|
+
expect(can('posts.create')).toBe(true)
|
|
572
|
+
expect(can('posts.delete')).toBe(false)
|
|
573
|
+
expect(can('users.read')).toBe(true)
|
|
574
|
+
expect(can('users.manage')).toBe(false)
|
|
575
|
+
expect(can('billing.view')).toBe(false)
|
|
576
576
|
|
|
577
577
|
// Promote to admin
|
|
578
|
-
can.set(fromRole(
|
|
579
|
-
expect(can(
|
|
580
|
-
expect(can(
|
|
581
|
-
expect(can(
|
|
578
|
+
can.set(fromRole('admin'))
|
|
579
|
+
expect(can('posts.delete')).toBe(true)
|
|
580
|
+
expect(can('users.manage')).toBe(true)
|
|
581
|
+
expect(can('billing.view')).toBe(true)
|
|
582
582
|
})
|
|
583
583
|
|
|
584
|
-
it(
|
|
584
|
+
it('feature flags mixed with permissions', () => {
|
|
585
585
|
const can = createPermissions({
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
586
|
+
'posts.read': true,
|
|
587
|
+
'posts.create': true,
|
|
588
|
+
'feature.new-editor': true,
|
|
589
|
+
'feature.dark-mode': false,
|
|
590
|
+
'tier.pro': true,
|
|
591
|
+
'tier.enterprise': false,
|
|
592
592
|
})
|
|
593
593
|
|
|
594
|
-
expect(can(
|
|
595
|
-
expect(can(
|
|
596
|
-
expect(can(
|
|
597
|
-
expect(can(
|
|
594
|
+
expect(can('feature.new-editor')).toBe(true)
|
|
595
|
+
expect(can('feature.dark-mode')).toBe(false)
|
|
596
|
+
expect(can('tier.pro')).toBe(true)
|
|
597
|
+
expect(can('tier.enterprise')).toBe(false)
|
|
598
598
|
})
|
|
599
599
|
|
|
600
|
-
it(
|
|
601
|
-
const currentUserId =
|
|
600
|
+
it('instance-level ownership checks', () => {
|
|
601
|
+
const currentUserId = 'user-42'
|
|
602
602
|
|
|
603
603
|
const can = createPermissions({
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
604
|
+
'posts.read': true,
|
|
605
|
+
'posts.update': (post: any) => post?.authorId === currentUserId,
|
|
606
|
+
'posts.delete': (post: any) => post?.authorId === currentUserId && post?.status === 'draft',
|
|
607
607
|
})
|
|
608
608
|
|
|
609
|
-
const myPost = { authorId:
|
|
610
|
-
const otherPost = { authorId:
|
|
611
|
-
const publishedPost = { authorId:
|
|
609
|
+
const myPost = { authorId: 'user-42', status: 'draft' }
|
|
610
|
+
const otherPost = { authorId: 'user-99', status: 'draft' }
|
|
611
|
+
const publishedPost = { authorId: 'user-42', status: 'published' }
|
|
612
612
|
|
|
613
|
-
expect(can(
|
|
614
|
-
expect(can(
|
|
615
|
-
expect(can(
|
|
616
|
-
expect(can(
|
|
613
|
+
expect(can('posts.update', myPost)).toBe(true)
|
|
614
|
+
expect(can('posts.update', otherPost)).toBe(false)
|
|
615
|
+
expect(can('posts.delete', myPost)).toBe(true)
|
|
616
|
+
expect(can('posts.delete', publishedPost)).toBe(false)
|
|
617
617
|
})
|
|
618
618
|
|
|
619
|
-
it(
|
|
619
|
+
it('multi-tenant with key prefixes', () => {
|
|
620
620
|
const can = createPermissions({
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
621
|
+
'org:acme.admin': true,
|
|
622
|
+
'ws:design.posts.*': true,
|
|
623
|
+
'ws:engineering.posts.read': true,
|
|
624
624
|
})
|
|
625
625
|
|
|
626
|
-
expect(can(
|
|
627
|
-
expect(can(
|
|
628
|
-
expect(can(
|
|
629
|
-
expect(can(
|
|
630
|
-
expect(can(
|
|
626
|
+
expect(can('org:acme.admin')).toBe(true)
|
|
627
|
+
expect(can('ws:design.posts.read')).toBe(true)
|
|
628
|
+
expect(can('ws:design.posts.delete')).toBe(true)
|
|
629
|
+
expect(can('ws:engineering.posts.read')).toBe(true)
|
|
630
|
+
expect(can('ws:engineering.posts.delete')).toBe(false)
|
|
631
631
|
})
|
|
632
632
|
|
|
633
|
-
it(
|
|
633
|
+
it('server response transformation', () => {
|
|
634
634
|
// Simulated server response
|
|
635
635
|
const serverResponse = {
|
|
636
|
-
permissions: [
|
|
636
|
+
permissions: ['posts:read', 'posts:create', 'users:read'],
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
// Simple transform — not a framework feature, just a .map()
|
|
640
640
|
const perms = Object.fromEntries(
|
|
641
|
-
serverResponse.permissions.map((p) => [p.replace(
|
|
641
|
+
serverResponse.permissions.map((p) => [p.replace(':', '.'), true]),
|
|
642
642
|
)
|
|
643
643
|
|
|
644
644
|
const can = createPermissions(perms)
|
|
645
|
-
expect(can(
|
|
646
|
-
expect(can(
|
|
647
|
-
expect(can(
|
|
648
|
-
expect(can(
|
|
645
|
+
expect(can('posts.read')).toBe(true)
|
|
646
|
+
expect(can('posts.create')).toBe(true)
|
|
647
|
+
expect(can('users.read')).toBe(true)
|
|
648
|
+
expect(can('posts.delete')).toBe(false)
|
|
649
649
|
})
|
|
650
650
|
|
|
651
|
-
it(
|
|
652
|
-
const can = createPermissions({
|
|
653
|
-
expect(can(
|
|
654
|
-
expect(can(
|
|
655
|
-
expect(can(
|
|
651
|
+
it('superadmin with global wildcard', () => {
|
|
652
|
+
const can = createPermissions({ '*': true })
|
|
653
|
+
expect(can('literally.anything')).toBe(true)
|
|
654
|
+
expect(can('posts.read')).toBe(true)
|
|
655
|
+
expect(can('admin.nuclear-launch')).toBe(true)
|
|
656
656
|
})
|
|
657
657
|
|
|
658
|
-
it(
|
|
658
|
+
it('reactive role switching', () => {
|
|
659
659
|
function fromRole(role: string): Record<string, boolean> {
|
|
660
|
-
if (role ===
|
|
661
|
-
if (role ===
|
|
662
|
-
return {
|
|
660
|
+
if (role === 'admin') return { '*': true }
|
|
661
|
+
if (role === 'editor') return { 'posts.*': true, 'users.read': true }
|
|
662
|
+
return { 'posts.read': true }
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
-
const can = createPermissions(fromRole(
|
|
665
|
+
const can = createPermissions(fromRole('viewer'))
|
|
666
666
|
const results: boolean[] = []
|
|
667
667
|
|
|
668
668
|
effect(() => {
|
|
669
|
-
results.push(can(
|
|
669
|
+
results.push(can('posts.create'))
|
|
670
670
|
})
|
|
671
671
|
|
|
672
672
|
expect(results).toEqual([false])
|
|
673
673
|
|
|
674
|
-
can.set(fromRole(
|
|
674
|
+
can.set(fromRole('editor'))
|
|
675
675
|
expect(results).toEqual([false, true])
|
|
676
676
|
|
|
677
|
-
can.set(fromRole(
|
|
677
|
+
can.set(fromRole('admin'))
|
|
678
678
|
expect(results).toEqual([false, true, true])
|
|
679
679
|
|
|
680
|
-
can.set(fromRole(
|
|
680
|
+
can.set(fromRole('viewer'))
|
|
681
681
|
expect(results).toEqual([false, true, true, false])
|
|
682
682
|
})
|
|
683
683
|
})
|