@pyreon/permissions 0.11.4 → 0.11.6
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/analysis/index.js.html +1 -1
- package/lib/index.js +11 -7
- 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 +18 -14
- 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,64 +1,64 @@
|
|
|
1
|
-
import { signal } from
|
|
2
|
-
import { describe, expect, it } from
|
|
3
|
-
import { createPermissions } from
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createPermissions } from '../index'
|
|
4
4
|
|
|
5
|
-
describe(
|
|
6
|
-
describe(
|
|
7
|
-
it(
|
|
5
|
+
describe('createPermissions — predicate permissions', () => {
|
|
6
|
+
describe('basic predicate evaluation', () => {
|
|
7
|
+
it('evaluates predicate with context argument', () => {
|
|
8
8
|
const can = createPermissions({
|
|
9
|
-
|
|
9
|
+
'posts.update': (post: any) => post?.authorId === 'user-1',
|
|
10
10
|
})
|
|
11
|
-
expect(can(
|
|
12
|
-
expect(can(
|
|
11
|
+
expect(can('posts.update', { authorId: 'user-1' })).toBe(true)
|
|
12
|
+
expect(can('posts.update', { authorId: 'user-2' })).toBe(false)
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
it(
|
|
15
|
+
it('predicate called without context receives undefined', () => {
|
|
16
16
|
const can = createPermissions({
|
|
17
|
-
|
|
17
|
+
'posts.update': (post?: any) => post?.authorId === 'user-1',
|
|
18
18
|
})
|
|
19
19
|
// No context — post is undefined
|
|
20
|
-
expect(can(
|
|
20
|
+
expect(can('posts.update')).toBe(false)
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
it(
|
|
23
|
+
it('predicate that ignores context (always true)', () => {
|
|
24
24
|
const can = createPermissions({
|
|
25
|
-
|
|
25
|
+
'posts.create': () => true,
|
|
26
26
|
})
|
|
27
|
-
expect(can(
|
|
28
|
-
expect(can(
|
|
27
|
+
expect(can('posts.create')).toBe(true)
|
|
28
|
+
expect(can('posts.create', { anything: true })).toBe(true)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
it(
|
|
31
|
+
it('predicate that ignores context (always false)', () => {
|
|
32
32
|
const can = createPermissions({
|
|
33
|
-
|
|
33
|
+
'posts.create': () => false,
|
|
34
34
|
})
|
|
35
|
-
expect(can(
|
|
35
|
+
expect(can('posts.create')).toBe(false)
|
|
36
36
|
})
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
describe(
|
|
40
|
-
it(
|
|
41
|
-
const currentUserId =
|
|
39
|
+
describe('complex predicate logic', () => {
|
|
40
|
+
it('ownership check', () => {
|
|
41
|
+
const currentUserId = 'user-42'
|
|
42
42
|
const can = createPermissions({
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
'posts.update': (post: any) => post?.authorId === currentUserId,
|
|
44
|
+
'posts.delete': (post: any) => post?.authorId === currentUserId && post?.status === 'draft',
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
const myDraft = { authorId:
|
|
48
|
-
const myPublished = { authorId:
|
|
49
|
-
const otherPost = { authorId:
|
|
47
|
+
const myDraft = { authorId: 'user-42', status: 'draft' }
|
|
48
|
+
const myPublished = { authorId: 'user-42', status: 'published' }
|
|
49
|
+
const otherPost = { authorId: 'user-99', status: 'draft' }
|
|
50
50
|
|
|
51
|
-
expect(can(
|
|
52
|
-
expect(can(
|
|
53
|
-
expect(can(
|
|
54
|
-
expect(can(
|
|
55
|
-
expect(can(
|
|
56
|
-
expect(can(
|
|
51
|
+
expect(can('posts.update', myDraft)).toBe(true)
|
|
52
|
+
expect(can('posts.update', myPublished)).toBe(true)
|
|
53
|
+
expect(can('posts.update', otherPost)).toBe(false)
|
|
54
|
+
expect(can('posts.delete', myDraft)).toBe(true)
|
|
55
|
+
expect(can('posts.delete', myPublished)).toBe(false)
|
|
56
|
+
expect(can('posts.delete', otherPost)).toBe(false)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
it(
|
|
59
|
+
it('predicate with multiple conditions', () => {
|
|
60
60
|
const can = createPermissions({
|
|
61
|
-
|
|
61
|
+
'orders.refund': (order: any) => {
|
|
62
62
|
if (!order) return false
|
|
63
63
|
const isRecent = Date.now() - order.createdAt < 86400000
|
|
64
64
|
const isSmall = order.amount < 100
|
|
@@ -70,80 +70,80 @@ describe("createPermissions — predicate permissions", () => {
|
|
|
70
70
|
const recentLarge = { createdAt: Date.now() - 1000, amount: 200 }
|
|
71
71
|
const oldSmall = { createdAt: Date.now() - 100_000_000, amount: 50 }
|
|
72
72
|
|
|
73
|
-
expect(can(
|
|
74
|
-
expect(can(
|
|
75
|
-
expect(can(
|
|
73
|
+
expect(can('orders.refund', recentSmall)).toBe(true)
|
|
74
|
+
expect(can('orders.refund', recentLarge)).toBe(false)
|
|
75
|
+
expect(can('orders.refund', oldSmall)).toBe(false)
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
it(
|
|
79
|
-
const role = signal(
|
|
78
|
+
it('predicate referencing reactive signal', () => {
|
|
79
|
+
const role = signal('admin')
|
|
80
80
|
const can = createPermissions({
|
|
81
|
-
|
|
81
|
+
'users.manage': () => role.peek() === 'admin',
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
-
expect(can(
|
|
84
|
+
expect(can('users.manage')).toBe(true)
|
|
85
85
|
|
|
86
|
-
role.set(
|
|
86
|
+
role.set('viewer')
|
|
87
87
|
// Need to trigger version update for reactive tracking,
|
|
88
88
|
// but predicate re-evaluation on read still reflects signal state
|
|
89
|
-
can.patch({
|
|
90
|
-
expect(can(
|
|
89
|
+
can.patch({ 'users.manage': () => role.peek() === 'admin' })
|
|
90
|
+
expect(can('users.manage')).toBe(false)
|
|
91
91
|
})
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
-
describe(
|
|
95
|
-
it(
|
|
94
|
+
describe('predicate error handling', () => {
|
|
95
|
+
it('returns false when predicate throws', () => {
|
|
96
96
|
const can = createPermissions({
|
|
97
|
-
|
|
98
|
-
throw new Error(
|
|
97
|
+
'posts.update': () => {
|
|
98
|
+
throw new Error('boom')
|
|
99
99
|
},
|
|
100
100
|
})
|
|
101
|
-
expect(can(
|
|
101
|
+
expect(can('posts.update')).toBe(false)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it(
|
|
104
|
+
it('returns false when predicate throws with context', () => {
|
|
105
105
|
const can = createPermissions({
|
|
106
|
-
|
|
107
|
-
throw new TypeError(
|
|
106
|
+
'posts.update': (_post: any) => {
|
|
107
|
+
throw new TypeError('property access failed')
|
|
108
108
|
},
|
|
109
109
|
})
|
|
110
|
-
expect(can(
|
|
110
|
+
expect(can('posts.update', { id: 1 })).toBe(false)
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
it(
|
|
113
|
+
it('can.not returns true when predicate throws', () => {
|
|
114
114
|
const can = createPermissions({
|
|
115
|
-
|
|
116
|
-
throw new Error(
|
|
115
|
+
'posts.update': () => {
|
|
116
|
+
throw new Error('error')
|
|
117
117
|
},
|
|
118
118
|
})
|
|
119
|
-
expect(can.not(
|
|
119
|
+
expect(can.not('posts.update')).toBe(true)
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
it(
|
|
122
|
+
it('other permissions unaffected when one predicate throws', () => {
|
|
123
123
|
const can = createPermissions({
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
throw new Error(
|
|
124
|
+
'posts.read': true,
|
|
125
|
+
'posts.update': () => {
|
|
126
|
+
throw new Error('boom')
|
|
127
127
|
},
|
|
128
|
-
|
|
128
|
+
'posts.create': () => true,
|
|
129
129
|
})
|
|
130
|
-
expect(can(
|
|
131
|
-
expect(can(
|
|
132
|
-
expect(can(
|
|
130
|
+
expect(can('posts.read')).toBe(true)
|
|
131
|
+
expect(can('posts.update')).toBe(false)
|
|
132
|
+
expect(can('posts.create')).toBe(true)
|
|
133
133
|
})
|
|
134
134
|
})
|
|
135
135
|
|
|
136
|
-
describe(
|
|
137
|
-
it(
|
|
136
|
+
describe('predicates in granted()', () => {
|
|
137
|
+
it('predicates count as capabilities in granted()', () => {
|
|
138
138
|
const can = createPermissions({
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
'posts.read': true,
|
|
140
|
+
'posts.update': (post: any) => post?.authorId === 'me',
|
|
141
|
+
'posts.delete': false,
|
|
142
142
|
})
|
|
143
143
|
const granted = can.granted()
|
|
144
|
-
expect(granted).toContain(
|
|
145
|
-
expect(granted).toContain(
|
|
146
|
-
expect(granted).not.toContain(
|
|
144
|
+
expect(granted).toContain('posts.read')
|
|
145
|
+
expect(granted).toContain('posts.update') // predicate = capability exists
|
|
146
|
+
expect(granted).not.toContain('posts.delete')
|
|
147
147
|
})
|
|
148
148
|
})
|
|
149
149
|
})
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
import { effect } from
|
|
2
|
-
import { describe, expect, it } from
|
|
3
|
-
import { createPermissions } from
|
|
4
|
-
|
|
5
|
-
describe(
|
|
6
|
-
describe(
|
|
7
|
-
it(
|
|
8
|
-
const can = createPermissions({
|
|
9
|
-
expect(can(
|
|
10
|
-
expect(can(
|
|
11
|
-
expect(can(
|
|
12
|
-
expect(can(
|
|
1
|
+
import { effect } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createPermissions } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('createPermissions — wildcard matching', () => {
|
|
6
|
+
describe('prefix wildcard (posts.*)', () => {
|
|
7
|
+
it('matches any sub-key under the prefix', () => {
|
|
8
|
+
const can = createPermissions({ 'posts.*': true })
|
|
9
|
+
expect(can('posts.read')).toBe(true)
|
|
10
|
+
expect(can('posts.create')).toBe(true)
|
|
11
|
+
expect(can('posts.update')).toBe(true)
|
|
12
|
+
expect(can('posts.delete')).toBe(true)
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
it(
|
|
16
|
-
const can = createPermissions({
|
|
17
|
-
expect(can(
|
|
15
|
+
it('does not match keys without a dot', () => {
|
|
16
|
+
const can = createPermissions({ 'posts.*': true })
|
|
17
|
+
expect(can('posts')).toBe(false) // no dot, no wildcard match
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it(
|
|
21
|
-
const can = createPermissions({
|
|
22
|
-
expect(can(
|
|
23
|
-
expect(can(
|
|
20
|
+
it('does not match different prefix', () => {
|
|
21
|
+
const can = createPermissions({ 'posts.*': true })
|
|
22
|
+
expect(can('users.read')).toBe(false)
|
|
23
|
+
expect(can('comments.read')).toBe(false)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
it(
|
|
27
|
-
const can = createPermissions({
|
|
28
|
-
expect(can(
|
|
26
|
+
it('does not match partial prefix overlap', () => {
|
|
27
|
+
const can = createPermissions({ 'post.*': true })
|
|
28
|
+
expect(can('posts.read')).toBe(false) // 'posts' !== 'post'
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
it(
|
|
31
|
+
it('exact match takes precedence over wildcard', () => {
|
|
32
32
|
const can = createPermissions({
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
'posts.*': true,
|
|
34
|
+
'posts.delete': false,
|
|
35
35
|
})
|
|
36
|
-
expect(can(
|
|
37
|
-
expect(can(
|
|
38
|
-
expect(can(
|
|
36
|
+
expect(can('posts.read')).toBe(true)
|
|
37
|
+
expect(can('posts.create')).toBe(true)
|
|
38
|
+
expect(can('posts.delete')).toBe(false) // exact overrides wildcard
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
it(
|
|
41
|
+
it('wildcard with predicate function', () => {
|
|
42
42
|
const can = createPermissions({
|
|
43
|
-
|
|
43
|
+
'posts.*': (post: any) => post?.status !== 'archived',
|
|
44
44
|
})
|
|
45
|
-
expect(can(
|
|
46
|
-
expect(can(
|
|
47
|
-
expect(can(
|
|
45
|
+
expect(can('posts.read', { status: 'published' })).toBe(true)
|
|
46
|
+
expect(can('posts.update', { status: 'archived' })).toBe(false)
|
|
47
|
+
expect(can('posts.delete', { status: 'draft' })).toBe(true)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
-
it(
|
|
50
|
+
it('multiple prefix wildcards for different namespaces', () => {
|
|
51
51
|
const can = createPermissions({
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
'posts.*': true,
|
|
53
|
+
'users.*': false,
|
|
54
54
|
})
|
|
55
|
-
expect(can(
|
|
56
|
-
expect(can(
|
|
55
|
+
expect(can('posts.read')).toBe(true)
|
|
56
|
+
expect(can('users.read')).toBe(false)
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
describe(
|
|
61
|
-
it(
|
|
62
|
-
const can = createPermissions({
|
|
63
|
-
expect(can(
|
|
64
|
-
expect(can(
|
|
65
|
-
expect(can(
|
|
66
|
-
expect(can(
|
|
60
|
+
describe('global wildcard (*)', () => {
|
|
61
|
+
it('matches any key', () => {
|
|
62
|
+
const can = createPermissions({ '*': true })
|
|
63
|
+
expect(can('posts.read')).toBe(true)
|
|
64
|
+
expect(can('users.manage')).toBe(true)
|
|
65
|
+
expect(can('anything.at.all')).toBe(true)
|
|
66
|
+
expect(can('simple-key')).toBe(true)
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
it(
|
|
69
|
+
it('exact match takes precedence over global wildcard', () => {
|
|
70
70
|
const can = createPermissions({
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
'*': true,
|
|
72
|
+
'billing.export': false,
|
|
73
73
|
})
|
|
74
|
-
expect(can(
|
|
75
|
-
expect(can(
|
|
74
|
+
expect(can('posts.read')).toBe(true)
|
|
75
|
+
expect(can('billing.export')).toBe(false)
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
it(
|
|
78
|
+
it('prefix wildcard takes precedence over global wildcard', () => {
|
|
79
79
|
const can = createPermissions({
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
'*': false,
|
|
81
|
+
'posts.*': true,
|
|
82
82
|
})
|
|
83
|
-
expect(can(
|
|
84
|
-
expect(can(
|
|
85
|
-
expect(can(
|
|
83
|
+
expect(can('posts.read')).toBe(true)
|
|
84
|
+
expect(can('posts.create')).toBe(true)
|
|
85
|
+
expect(can('users.manage')).toBe(false) // falls to global *
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
it(
|
|
88
|
+
it('resolution order: exact > prefix wildcard > global wildcard', () => {
|
|
89
89
|
const can = createPermissions({
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
'*': false,
|
|
91
|
+
'posts.*': true,
|
|
92
|
+
'posts.delete': false,
|
|
93
93
|
})
|
|
94
|
-
expect(can(
|
|
95
|
-
expect(can(
|
|
96
|
-
expect(can(
|
|
94
|
+
expect(can('posts.read')).toBe(true) // matched by posts.*
|
|
95
|
+
expect(can('posts.delete')).toBe(false) // exact match
|
|
96
|
+
expect(can('users.manage')).toBe(false) // matched by *
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
-
it(
|
|
99
|
+
it('global wildcard with predicate', () => {
|
|
100
100
|
const can = createPermissions({
|
|
101
|
-
|
|
101
|
+
'*': (ctx: any) => ctx?.role === 'admin',
|
|
102
102
|
})
|
|
103
|
-
expect(can(
|
|
104
|
-
expect(can(
|
|
103
|
+
expect(can('posts.read', { role: 'admin' })).toBe(true)
|
|
104
|
+
expect(can('posts.read', { role: 'viewer' })).toBe(false)
|
|
105
105
|
})
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
-
describe(
|
|
109
|
-
it(
|
|
110
|
-
const can = createPermissions({
|
|
108
|
+
describe('wildcard reactivity', () => {
|
|
109
|
+
it('wildcards are reactive after set()', () => {
|
|
110
|
+
const can = createPermissions({ 'posts.*': false })
|
|
111
111
|
const results: boolean[] = []
|
|
112
112
|
|
|
113
113
|
effect(() => {
|
|
114
|
-
results.push(can(
|
|
114
|
+
results.push(can('posts.read'))
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
can.set({
|
|
117
|
+
can.set({ 'posts.*': true })
|
|
118
118
|
expect(results).toEqual([false, true])
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
-
it(
|
|
122
|
-
const can = createPermissions({
|
|
121
|
+
it('wildcards are reactive after patch()', () => {
|
|
122
|
+
const can = createPermissions({ 'posts.*': true })
|
|
123
123
|
const results: boolean[] = []
|
|
124
124
|
|
|
125
125
|
effect(() => {
|
|
126
|
-
results.push(can(
|
|
126
|
+
results.push(can('posts.read'))
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
-
can.patch({
|
|
129
|
+
can.patch({ 'posts.read': false }) // exact override
|
|
130
130
|
expect(results).toEqual([true, false])
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
it(
|
|
134
|
-
const can = createPermissions({
|
|
135
|
-
expect(can(
|
|
133
|
+
it('removing wildcard via set() removes its effect', () => {
|
|
134
|
+
const can = createPermissions({ 'posts.*': true })
|
|
135
|
+
expect(can('posts.read')).toBe(true)
|
|
136
136
|
|
|
137
137
|
can.set({}) // remove all permissions
|
|
138
|
-
expect(can(
|
|
138
|
+
expect(can('posts.read')).toBe(false)
|
|
139
139
|
})
|
|
140
140
|
})
|
|
141
141
|
})
|
package/src/types.ts
CHANGED