interaqt 0.3.0 → 0.3.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/agent/.claude/agents/code-generation-handler.md +2 -0
- package/agent/.claude/agents/computation-generation-handler.md +1 -0
- package/agent/.claude/agents/implement-design-handler.md +4 -13
- package/agent/.claude/agents/requirements-analysis-handler.md +46 -14
- package/agent/agentspace/knowledge/generator/api-reference.md +3378 -0
- package/agent/agentspace/knowledge/generator/basic-interaction-generation.md +377 -0
- package/agent/agentspace/knowledge/generator/computation-analysis.md +307 -0
- package/agent/agentspace/knowledge/generator/computation-implementation.md +959 -0
- package/agent/agentspace/knowledge/generator/data-analysis.md +463 -0
- package/agent/agentspace/knowledge/generator/entity-relation-generation.md +395 -0
- package/agent/agentspace/knowledge/generator/permission-implementation.md +460 -0
- package/agent/agentspace/knowledge/generator/permission-test-implementation.md +870 -0
- package/agent/agentspace/knowledge/generator/test-implementation.md +674 -0
- package/agent/agentspace/knowledge/usage/00-mindset-shift.md +322 -0
- package/agent/agentspace/knowledge/usage/01-core-concepts.md +131 -0
- package/agent/agentspace/knowledge/usage/02-define-entities-properties.md +407 -0
- package/agent/agentspace/knowledge/usage/03-entity-relations.md +599 -0
- package/agent/agentspace/knowledge/usage/04-reactive-computations.md +2186 -0
- package/agent/agentspace/knowledge/usage/05-interactions.md +1411 -0
- package/agent/agentspace/knowledge/usage/06-attributive-permissions.md +10 -0
- package/agent/agentspace/knowledge/usage/07-payload-parameters.md +593 -0
- package/agent/agentspace/knowledge/usage/08-activities.md +863 -0
- package/agent/agentspace/knowledge/usage/09-filtered-entities.md +784 -0
- package/agent/agentspace/knowledge/usage/10-async-computations.md +734 -0
- package/agent/agentspace/knowledge/usage/11-global-dictionaries.md +942 -0
- package/agent/agentspace/knowledge/usage/12-data-querying.md +1033 -0
- package/agent/agentspace/knowledge/usage/13-testing.md +1201 -0
- package/agent/agentspace/knowledge/usage/14-api-reference.md +1606 -0
- package/agent/agentspace/knowledge/usage/15-entity-crud-patterns.md +1122 -0
- package/agent/agentspace/knowledge/usage/16-frontend-page-design-guide.md +485 -0
- package/agent/agentspace/knowledge/usage/17-performance-optimization.md +283 -0
- package/agent/agentspace/knowledge/usage/18-api-exports-reference.md +176 -0
- package/agent/agentspace/knowledge/usage/19-common-anti-patterns.md +563 -0
- package/agent/agentspace/knowledge/usage/README.md +148 -0
- package/package.json +1 -1
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
# Permission Test Implementation Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Permission testing verifies that conditions correctly control access to interactions. Tests should cover both allowed and denied scenarios for different user roles and data states.
|
|
5
|
+
|
|
6
|
+
### Key Testing Pattern
|
|
7
|
+
When testing permission failures, always verify:
|
|
8
|
+
1. **Error exists**: `expect(result.error).toBeDefined()`
|
|
9
|
+
2. **Error type**: `expect((result.error as ConditionError).type).toBe('condition check failed')`
|
|
10
|
+
3. **Which condition failed**: `expect((result.error as ConditionError).error.data.name).toBe('ConditionName')`
|
|
11
|
+
|
|
12
|
+
This detailed verification helps identify exactly which permission check failed, making debugging easier.
|
|
13
|
+
|
|
14
|
+
## 🔴 CRITICAL: Permission Testing Principles
|
|
15
|
+
|
|
16
|
+
### Error Handling Pattern
|
|
17
|
+
```typescript
|
|
18
|
+
// ❌ WRONG: interaqt doesn't throw exceptions
|
|
19
|
+
try {
|
|
20
|
+
await controller.callInteraction('DeleteStyle', {
|
|
21
|
+
user: viewer,
|
|
22
|
+
payload: { style: { id: styleId } }
|
|
23
|
+
})
|
|
24
|
+
fail('Should have thrown')
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// This will never execute
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ✅ CORRECT: Check error in result with detailed verification
|
|
30
|
+
import {ConditionError} from "interaqt"
|
|
31
|
+
const result = await controller.callInteraction('DeleteStyle', {
|
|
32
|
+
user: viewer,
|
|
33
|
+
payload: { style: { id: styleId } }
|
|
34
|
+
})
|
|
35
|
+
expect(result.error).toBeDefined()
|
|
36
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
37
|
+
// Verify which specific condition failed
|
|
38
|
+
expect((result.error as ConditionError).error.data.name).toBe('AdminRole')
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Common Error Types
|
|
42
|
+
- `'no permission'` → Never used (legacy)
|
|
43
|
+
- `'condition check failed'` → What you'll actually see
|
|
44
|
+
|
|
45
|
+
## Testing Permission Patterns
|
|
46
|
+
|
|
47
|
+
### 1. Role-Based Permission Test
|
|
48
|
+
|
|
49
|
+
Define permissions:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { Condition, BoolExp, Conditions, Interaction, Action, Payload, PayloadItem, MatchExp, Controller, ConditionError } from 'interaqt'
|
|
53
|
+
|
|
54
|
+
// Step 1: Define Conditions
|
|
55
|
+
export const AdminRole = Condition.create({
|
|
56
|
+
name: 'AdminRole',
|
|
57
|
+
content: async function(this: Controller, event) {
|
|
58
|
+
return event.user?.role === 'admin'
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const OperatorRole = Condition.create({
|
|
63
|
+
name: 'OperatorRole',
|
|
64
|
+
content: async function(this: Controller, event) {
|
|
65
|
+
return event.user?.role === 'operator'
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export const StyleNotOffline = Condition.create({
|
|
70
|
+
name: 'StyleNotOffline',
|
|
71
|
+
content: async function(this: Controller, event) {
|
|
72
|
+
const styleId = event.payload?.style?.id
|
|
73
|
+
if (!styleId) return false
|
|
74
|
+
|
|
75
|
+
const style = await this.system.storage.findOne('Style',
|
|
76
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] }),
|
|
77
|
+
undefined,
|
|
78
|
+
['status']
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return style?.status !== 'offline'
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Step 2: Create Interaction with Permissions
|
|
86
|
+
export const DeleteStyle = Interaction.create({
|
|
87
|
+
name: 'DeleteStyle',
|
|
88
|
+
action: Action.create({ name: 'deleteStyle' }),
|
|
89
|
+
payload: Payload.create({
|
|
90
|
+
items: [
|
|
91
|
+
PayloadItem.create({
|
|
92
|
+
name: 'style',
|
|
93
|
+
base: Style,
|
|
94
|
+
isRef: true
|
|
95
|
+
})
|
|
96
|
+
]
|
|
97
|
+
}),
|
|
98
|
+
conditions: AdminRole // Only admin can delete
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Test implementation:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
test('role-based permission', async () => {
|
|
106
|
+
// Step 1: Create test users
|
|
107
|
+
const admin = await system.storage.create('User', {
|
|
108
|
+
name: 'Admin',
|
|
109
|
+
role: 'admin'
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const operator = await system.storage.create('User', {
|
|
113
|
+
name: 'Operator',
|
|
114
|
+
role: 'operator'
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const viewer = await system.storage.create('User', {
|
|
118
|
+
name: 'Viewer',
|
|
119
|
+
role: 'viewer'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Step 2: Create test data
|
|
123
|
+
const style = await system.storage.create('Style', {
|
|
124
|
+
label: 'Test Style',
|
|
125
|
+
status: 'published'
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Step 3: Test admin (allowed)
|
|
129
|
+
const adminResult = await controller.callInteraction('DeleteStyle', {
|
|
130
|
+
user: admin,
|
|
131
|
+
payload: { style: { id: style.id } }
|
|
132
|
+
})
|
|
133
|
+
expect(adminResult.error).toBeUndefined()
|
|
134
|
+
|
|
135
|
+
// Step 4: Test operator (denied)
|
|
136
|
+
const operatorResult = await controller.callInteraction('DeleteStyle', {
|
|
137
|
+
user: operator,
|
|
138
|
+
payload: { style: { id: style.id } }
|
|
139
|
+
})
|
|
140
|
+
expect(operatorResult.error).toBeDefined()
|
|
141
|
+
expect((operatorResult.error as ConditionError).type).toBe('condition check failed')
|
|
142
|
+
expect((operatorResult.error as ConditionError).error.data.name).toBe('AdminRole')
|
|
143
|
+
|
|
144
|
+
// Step 5: Test viewer (denied)
|
|
145
|
+
const viewerResult = await controller.callInteraction('DeleteStyle', {
|
|
146
|
+
user: viewer,
|
|
147
|
+
payload: { style: { id: style.id } }
|
|
148
|
+
})
|
|
149
|
+
expect(viewerResult.error).toBeDefined()
|
|
150
|
+
expect((viewerResult.error as ConditionError).type).toBe('condition check failed')
|
|
151
|
+
expect((viewerResult.error as ConditionError).error.data.name).toBe('AdminRole')
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 2. Combined Permission Test
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// Define combined permission
|
|
159
|
+
export const UpdateStyle = Interaction.create({
|
|
160
|
+
name: 'UpdateStyle',
|
|
161
|
+
action: Action.create({ name: 'updateStyle' }),
|
|
162
|
+
payload: Payload.create({
|
|
163
|
+
items: [
|
|
164
|
+
PayloadItem.create({
|
|
165
|
+
name: 'style',
|
|
166
|
+
base: Style,
|
|
167
|
+
isRef: true
|
|
168
|
+
})
|
|
169
|
+
]
|
|
170
|
+
}),
|
|
171
|
+
// Admin OR Operator AND style not offline
|
|
172
|
+
conditions: Conditions.create({
|
|
173
|
+
content: BoolExp.atom(AdminRole)
|
|
174
|
+
.or(BoolExp.atom(OperatorRole))
|
|
175
|
+
.and(BoolExp.atom(StyleNotOffline))
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Test implementation
|
|
180
|
+
test('combined permissions with BoolExp', async () => {
|
|
181
|
+
// Create users
|
|
182
|
+
const admin = await system.storage.create('User', {
|
|
183
|
+
name: 'Admin',
|
|
184
|
+
role: 'admin'
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const operator = await system.storage.create('User', {
|
|
188
|
+
name: 'Operator',
|
|
189
|
+
role: 'operator'
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const viewer = await system.storage.create('User', {
|
|
193
|
+
name: 'Viewer',
|
|
194
|
+
role: 'viewer'
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Create styles
|
|
198
|
+
const publishedStyle = await system.storage.create('Style', {
|
|
199
|
+
label: 'Published Style',
|
|
200
|
+
status: 'published'
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const offlineStyle = await system.storage.create('Style', {
|
|
204
|
+
label: 'Offline Style',
|
|
205
|
+
status: 'offline'
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Test admin with published style (allowed)
|
|
209
|
+
const result1 = await controller.callInteraction('UpdateStyle', {
|
|
210
|
+
user: admin,
|
|
211
|
+
payload: { style: { id: publishedStyle.id } }
|
|
212
|
+
})
|
|
213
|
+
expect(result1.error).toBeUndefined()
|
|
214
|
+
|
|
215
|
+
// Test operator with published style (allowed)
|
|
216
|
+
const result2 = await controller.callInteraction('UpdateStyle', {
|
|
217
|
+
user: operator,
|
|
218
|
+
payload: { style: { id: publishedStyle.id } }
|
|
219
|
+
})
|
|
220
|
+
expect(result2.error).toBeUndefined()
|
|
221
|
+
|
|
222
|
+
// Test viewer with published style (denied)
|
|
223
|
+
const result3 = await controller.callInteraction('UpdateStyle', {
|
|
224
|
+
user: viewer,
|
|
225
|
+
payload: { style: { id: publishedStyle.id } }
|
|
226
|
+
})
|
|
227
|
+
expect(result3.error).toBeDefined()
|
|
228
|
+
expect((result3.error as ConditionError).type).toBe('condition check failed')
|
|
229
|
+
// With combined conditions, the first failing condition is reported
|
|
230
|
+
expect((result3.error as ConditionError).error.data.name).toBeDefined()
|
|
231
|
+
|
|
232
|
+
// Test admin with offline style (denied - even admin can't update offline)
|
|
233
|
+
const result4 = await controller.callInteraction('UpdateStyle', {
|
|
234
|
+
user: admin,
|
|
235
|
+
payload: { style: { id: offlineStyle.id } }
|
|
236
|
+
})
|
|
237
|
+
expect(result4.error).toBeDefined()
|
|
238
|
+
expect((result4.error as ConditionError).type).toBe('condition check failed')
|
|
239
|
+
expect((result4.error as ConditionError).error.data.name).toBe('StyleNotOffline')
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### 3. Resource-Based Permission Test
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// Define owner check
|
|
247
|
+
export const OwnerOnly = Condition.create({
|
|
248
|
+
name: 'OwnerOnly',
|
|
249
|
+
content: async function(this: Controller, event) {
|
|
250
|
+
const styleId = event.payload?.style?.id
|
|
251
|
+
if (!styleId) return false
|
|
252
|
+
|
|
253
|
+
const style = await this.system.storage.findOne('Style',
|
|
254
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] }),
|
|
255
|
+
undefined,
|
|
256
|
+
['creator'] // Must specify attributeQuery!
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return style?.creator?.id === event.user?.id
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Interaction that allows owner or admin
|
|
264
|
+
export const DeleteOwnStyle = Interaction.create({
|
|
265
|
+
name: 'DeleteOwnStyle',
|
|
266
|
+
action: Action.create({ name: 'deleteOwnStyle' }),
|
|
267
|
+
payload: Payload.create({
|
|
268
|
+
items: [
|
|
269
|
+
PayloadItem.create({
|
|
270
|
+
name: 'style',
|
|
271
|
+
base: Style,
|
|
272
|
+
isRef: true
|
|
273
|
+
})
|
|
274
|
+
]
|
|
275
|
+
}),
|
|
276
|
+
conditions: Conditions.create({
|
|
277
|
+
content: BoolExp.atom(OwnerOnly).or(BoolExp.atom(AdminRole))
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Test
|
|
282
|
+
test('resource ownership permission', async () => {
|
|
283
|
+
// Create users
|
|
284
|
+
const owner = await system.storage.create('User', {
|
|
285
|
+
name: 'Owner',
|
|
286
|
+
role: 'user'
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const otherUser = await system.storage.create('User', {
|
|
290
|
+
name: 'Other User',
|
|
291
|
+
role: 'user'
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const admin = await system.storage.create('User', {
|
|
295
|
+
name: 'Admin',
|
|
296
|
+
role: 'admin'
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// Create style with owner
|
|
300
|
+
const style = await system.storage.create('Style', {
|
|
301
|
+
label: 'My Style',
|
|
302
|
+
creator: { id: owner.id }
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Owner can delete (allowed)
|
|
306
|
+
const ownerResult = await controller.callInteraction('DeleteOwnStyle', {
|
|
307
|
+
user: owner,
|
|
308
|
+
payload: { style: { id: style.id } }
|
|
309
|
+
})
|
|
310
|
+
expect(ownerResult.error).toBeUndefined()
|
|
311
|
+
|
|
312
|
+
// Other user cannot delete (denied)
|
|
313
|
+
const otherResult = await controller.callInteraction('DeleteOwnStyle', {
|
|
314
|
+
user: otherUser,
|
|
315
|
+
payload: { style: { id: style.id } }
|
|
316
|
+
})
|
|
317
|
+
expect(otherResult.error).toBeDefined()
|
|
318
|
+
expect((otherResult.error as ConditionError).type).toBe('condition check failed')
|
|
319
|
+
// Should fail on OwnerOnly condition
|
|
320
|
+
expect((otherResult.error as ConditionError).error.data.name).toBe('OwnerOnly')
|
|
321
|
+
|
|
322
|
+
// Admin can delete any style (allowed)
|
|
323
|
+
const adminResult = await controller.callInteraction('DeleteOwnStyle', {
|
|
324
|
+
user: admin,
|
|
325
|
+
payload: { style: { id: style.id } }
|
|
326
|
+
})
|
|
327
|
+
expect(adminResult.error).toBeUndefined()
|
|
328
|
+
})
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### 4. Data Retrieval Permission Test
|
|
332
|
+
|
|
333
|
+
Test permissions for data retrieval interactions using GetAction:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
import { GetAction, Query, QueryItem, Condition, Controller, MatchExp, ConditionError } from 'interaqt'
|
|
337
|
+
|
|
338
|
+
// Check query-based permissions
|
|
339
|
+
export const CanViewPrivateData = Condition.create({
|
|
340
|
+
name: 'CanViewPrivateData',
|
|
341
|
+
content: async function(this: Controller, event) {
|
|
342
|
+
if (event.user?.role === 'admin') return true
|
|
343
|
+
|
|
344
|
+
const queryMatch = event.query?.match
|
|
345
|
+
if (!queryMatch) return true // No filter means public data
|
|
346
|
+
|
|
347
|
+
// Users can only query their own data
|
|
348
|
+
if (queryMatch.key === 'owner.id') {
|
|
349
|
+
return queryMatch.value?.[1] === event.user?.id
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Allow public status queries
|
|
353
|
+
if (queryMatch.key === 'status') {
|
|
354
|
+
const status = queryMatch.value?.[1]
|
|
355
|
+
return status === 'public' || status === 'published'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// Department-based access control
|
|
363
|
+
export const DepartmentDataAccess = Condition.create({
|
|
364
|
+
name: 'DepartmentDataAccess',
|
|
365
|
+
content: async function(this: Controller, event) {
|
|
366
|
+
const userDept = event.user?.department
|
|
367
|
+
if (!userDept) return false
|
|
368
|
+
|
|
369
|
+
const queryMatch = event.query?.match
|
|
370
|
+
if (queryMatch?.key === 'department') {
|
|
371
|
+
return queryMatch.value?.[1] === userDept
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Apply conditions to data retrieval
|
|
379
|
+
export const GetUserDocuments = Interaction.create({
|
|
380
|
+
name: 'GetUserDocuments',
|
|
381
|
+
action: GetAction,
|
|
382
|
+
data: Document,
|
|
383
|
+
conditions: CanViewPrivateData
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Test implementation
|
|
387
|
+
test('data retrieval permissions based on query', async () => {
|
|
388
|
+
const admin = await system.storage.create('User', {
|
|
389
|
+
role: 'admin'
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const user1 = await system.storage.create('User', {
|
|
393
|
+
role: 'user'
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const user2 = await system.storage.create('User', {
|
|
397
|
+
role: 'user'
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
// Create documents
|
|
401
|
+
await system.storage.create('Document', {
|
|
402
|
+
title: 'Private Doc',
|
|
403
|
+
status: 'private',
|
|
404
|
+
owner: { id: user1.id }
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// Admin can view private documents
|
|
408
|
+
const adminResult = await controller.callInteraction('GetUserDocuments', {
|
|
409
|
+
user: admin,
|
|
410
|
+
query: {
|
|
411
|
+
match: MatchExp.atom({ key: 'status', value: ['=', 'private'] }),
|
|
412
|
+
attributeQuery: ['id', 'title', 'status']
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
expect(adminResult.error).toBeUndefined()
|
|
416
|
+
|
|
417
|
+
// User can view own documents
|
|
418
|
+
const ownResult = await controller.callInteraction('GetUserDocuments', {
|
|
419
|
+
user: user1,
|
|
420
|
+
query: {
|
|
421
|
+
match: MatchExp.atom({ key: 'owner.id', value: ['=', user1.id] }),
|
|
422
|
+
attributeQuery: ['id', 'title']
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
expect(ownResult.error).toBeUndefined()
|
|
426
|
+
|
|
427
|
+
// User cannot view others' documents
|
|
428
|
+
const othersResult = await controller.callInteraction('GetUserDocuments', {
|
|
429
|
+
user: user1,
|
|
430
|
+
query: {
|
|
431
|
+
match: MatchExp.atom({ key: 'owner.id', value: ['=', user2.id] }),
|
|
432
|
+
attributeQuery: ['id', 'title']
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
expect(othersResult.error).toBeDefined()
|
|
436
|
+
expect((othersResult.error as ConditionError).type).toBe('condition check failed')
|
|
437
|
+
expect((othersResult.error as ConditionError).error.data.name).toBe('CanViewPrivateData')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// Pagination limits based on user role
|
|
441
|
+
export const EnforcePaginationLimits = Condition.create({
|
|
442
|
+
name: 'EnforcePaginationLimits',
|
|
443
|
+
content: async function(this: Controller, event) {
|
|
444
|
+
const modifier = event.query?.modifier
|
|
445
|
+
const maxLimits = { admin: 1000, premium: 500, user: 100 }
|
|
446
|
+
const userLimit = maxLimits[event.user?.role] || 50
|
|
447
|
+
|
|
448
|
+
if (modifier?.limit && modifier.limit > userLimit) {
|
|
449
|
+
event.error = `Limit exceeds maximum allowed (${userLimit})`
|
|
450
|
+
return false
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return true
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### 5. Payload Condition Test
|
|
459
|
+
|
|
460
|
+
Define payload conditions:
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// Check if style is published
|
|
464
|
+
export const CheckPublishedStyle = Condition.create({
|
|
465
|
+
name: 'CheckPublishedStyle',
|
|
466
|
+
content: async function(this: Controller, event) {
|
|
467
|
+
const styleId = event.payload?.style?.id
|
|
468
|
+
if (!styleId) return false
|
|
469
|
+
|
|
470
|
+
const style = await this.system.storage.findOne('Style',
|
|
471
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] }),
|
|
472
|
+
undefined,
|
|
473
|
+
['status']
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
return style && style.status === 'published'
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// Apply to interaction
|
|
481
|
+
export const ShareStyle = Interaction.create({
|
|
482
|
+
name: 'ShareStyle',
|
|
483
|
+
action: Action.create({ name: 'shareStyle' }),
|
|
484
|
+
payload: Payload.create({
|
|
485
|
+
items: [
|
|
486
|
+
PayloadItem.create({
|
|
487
|
+
name: 'style',
|
|
488
|
+
base: Style,
|
|
489
|
+
isRef: true
|
|
490
|
+
})
|
|
491
|
+
]
|
|
492
|
+
}),
|
|
493
|
+
conditions: Conditions.create({
|
|
494
|
+
content: BoolExp.atom(CheckPublishedStyle).and(BoolExp.atom(OperatorRole))
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
// Test payload validation
|
|
499
|
+
test('payload validation in conditions', async () => {
|
|
500
|
+
const operator = await system.storage.create('User', {
|
|
501
|
+
name: 'Operator',
|
|
502
|
+
role: 'operator'
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const publishedStyle = await system.storage.create('Style', {
|
|
506
|
+
label: 'Published',
|
|
507
|
+
status: 'published'
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const draftStyle = await system.storage.create('Style', {
|
|
511
|
+
label: 'Draft',
|
|
512
|
+
status: 'draft'
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// Published style can be shared (allowed)
|
|
516
|
+
const result1 = await controller.callInteraction('ShareStyle', {
|
|
517
|
+
user: operator,
|
|
518
|
+
payload: { style: { id: publishedStyle.id } }
|
|
519
|
+
})
|
|
520
|
+
expect(result1.error).toBeUndefined()
|
|
521
|
+
|
|
522
|
+
// Draft style cannot be shared (denied)
|
|
523
|
+
const result2 = await controller.callInteraction('ShareStyle', {
|
|
524
|
+
user: operator,
|
|
525
|
+
payload: { style: { id: draftStyle.id } }
|
|
526
|
+
})
|
|
527
|
+
expect(result2.error).toBeDefined()
|
|
528
|
+
expect((result2.error as ConditionError).type).toBe('condition check failed')
|
|
529
|
+
// Should fail on CheckPublishedStyle condition
|
|
530
|
+
expect((result2.error as ConditionError).error.data.name).toBe('CheckPublishedStyle')
|
|
531
|
+
})
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Best Practices for Permission Testing
|
|
535
|
+
|
|
536
|
+
### 1. Test All Branches
|
|
537
|
+
```typescript
|
|
538
|
+
test('comprehensive permission coverage', async () => {
|
|
539
|
+
// Test all roles
|
|
540
|
+
const roles = ['admin', 'operator', 'viewer', 'user']
|
|
541
|
+
|
|
542
|
+
for (const role of roles) {
|
|
543
|
+
const user = await system.storage.create('User', {
|
|
544
|
+
name: `${role} user`,
|
|
545
|
+
role: role
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
const result = await controller.callInteraction('AdminOnlyAction', {
|
|
549
|
+
user: user
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
if (role === 'admin') {
|
|
553
|
+
expect(result.error).toBeUndefined()
|
|
554
|
+
} else {
|
|
555
|
+
expect(result.error).toBeDefined()
|
|
556
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
557
|
+
expect((result.error as ConditionError).error.data.name).toBe('AdminRole')
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### 2. Test Edge Cases
|
|
564
|
+
```typescript
|
|
565
|
+
test('edge cases in permissions', async () => {
|
|
566
|
+
// Test with null user
|
|
567
|
+
const result1 = await controller.callInteraction('RequireAuth', {
|
|
568
|
+
user: null
|
|
569
|
+
})
|
|
570
|
+
expect(result1.error).toBeDefined()
|
|
571
|
+
|
|
572
|
+
// Test with missing payload data
|
|
573
|
+
const user = await system.storage.create('User', { role: 'admin' })
|
|
574
|
+
const result2 = await controller.callInteraction('UpdateStyle', {
|
|
575
|
+
user: user,
|
|
576
|
+
payload: {} // Missing style
|
|
577
|
+
})
|
|
578
|
+
expect(result2.error).toBeDefined()
|
|
579
|
+
|
|
580
|
+
// Test with non-existent resource
|
|
581
|
+
const result3 = await controller.callInteraction('UpdateStyle', {
|
|
582
|
+
user: user,
|
|
583
|
+
payload: { style: { id: 'non-existent-id' } }
|
|
584
|
+
})
|
|
585
|
+
expect(result3.error).toBeDefined()
|
|
586
|
+
})
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### 3. Test Complex Conditions
|
|
590
|
+
```typescript
|
|
591
|
+
test('complex permission logic', async () => {
|
|
592
|
+
// Define time-based condition
|
|
593
|
+
const BusinessHours = Condition.create({
|
|
594
|
+
name: 'BusinessHours',
|
|
595
|
+
content: async function(this: Controller, event) {
|
|
596
|
+
const hour = new Date().getHours()
|
|
597
|
+
return hour >= 9 && hour < 17
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
// Active user check
|
|
602
|
+
const ActiveUser = Condition.create({
|
|
603
|
+
name: 'ActiveUser',
|
|
604
|
+
content: async function(this: Controller, event) {
|
|
605
|
+
return event.user?.status === 'active'
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// Complex interaction
|
|
610
|
+
const SensitiveAction = Interaction.create({
|
|
611
|
+
name: 'SensitiveAction',
|
|
612
|
+
action: Action.create({ name: 'sensitive' }),
|
|
613
|
+
conditions: Conditions.create({
|
|
614
|
+
content: BoolExp.atom(AdminRole)
|
|
615
|
+
.and(BoolExp.atom(ActiveUser))
|
|
616
|
+
.and(BoolExp.atom(BusinessHours))
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
// Test all combinations
|
|
621
|
+
const activeAdmin = await system.storage.create('User', {
|
|
622
|
+
role: 'admin',
|
|
623
|
+
status: 'active'
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
const inactiveAdmin = await system.storage.create('User', {
|
|
627
|
+
role: 'admin',
|
|
628
|
+
status: 'inactive'
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
// Mock time if needed for consistent tests
|
|
632
|
+
// ... test logic
|
|
633
|
+
})
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### 4. Verify Detailed Error Information
|
|
637
|
+
```typescript
|
|
638
|
+
test('verify detailed condition error information', async () => {
|
|
639
|
+
// When a condition fails, verify all error details
|
|
640
|
+
const result = await controller.callInteraction('AdminOnlyAction', {
|
|
641
|
+
user: normalUser
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
// Basic error checks
|
|
645
|
+
expect(result.error).toBeDefined()
|
|
646
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
647
|
+
|
|
648
|
+
// Detailed error verification - identify which condition failed
|
|
649
|
+
expect((result.error as ConditionError).error.data.name).toBe('AdminRole')
|
|
650
|
+
|
|
651
|
+
// For combined conditions, test each failure scenario
|
|
652
|
+
const complexResult = await controller.callInteraction('ComplexAction', {
|
|
653
|
+
user: unverifiedAdmin // Admin but not verified
|
|
654
|
+
})
|
|
655
|
+
expect(complexResult.error).toBeDefined()
|
|
656
|
+
expect((complexResult.error as ConditionError).type).toBe('condition check failed')
|
|
657
|
+
// Should report the specific condition that failed
|
|
658
|
+
expect((complexResult.error as ConditionError).error.data.name).toBe('EmailVerified')
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('meaningful error messages', async () => {
|
|
662
|
+
// Define condition with custom error
|
|
663
|
+
const CustomError = Condition.create({
|
|
664
|
+
name: 'CustomError',
|
|
665
|
+
content: async function(this: Controller, event) {
|
|
666
|
+
if (!event.user) {
|
|
667
|
+
event.error = 'User authentication required'
|
|
668
|
+
return false
|
|
669
|
+
}
|
|
670
|
+
if (event.user.credits < 10) {
|
|
671
|
+
event.error = 'Insufficient credits (minimum: 10)'
|
|
672
|
+
return false
|
|
673
|
+
}
|
|
674
|
+
return true
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// Test error messages
|
|
679
|
+
const poorUser = await system.storage.create('User', {
|
|
680
|
+
credits: 5
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
const result = await controller.callInteraction('PremiumAction', {
|
|
684
|
+
user: poorUser
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
expect(result.error).toBeDefined()
|
|
688
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
689
|
+
expect((result.error as ConditionError).error.data.name).toBe('CustomError')
|
|
690
|
+
expect((result.error as ConditionError).message).toContain('Insufficient credits')
|
|
691
|
+
})
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### 5. Test State-Dependent Permissions
|
|
695
|
+
```typescript
|
|
696
|
+
test('state-dependent permissions', async () => {
|
|
697
|
+
// User must have verified email
|
|
698
|
+
const VerifiedEmail = Condition.create({
|
|
699
|
+
name: 'VerifiedEmail',
|
|
700
|
+
content: async function(this: Controller, event) {
|
|
701
|
+
return event.user?.emailVerified === true
|
|
702
|
+
}
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
// User must not be banned
|
|
706
|
+
const NotBanned = Condition.create({
|
|
707
|
+
name: 'NotBanned',
|
|
708
|
+
content: async function(this: Controller, event) {
|
|
709
|
+
return event.user?.banned !== true
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
// Apply multiple state checks
|
|
714
|
+
const PostComment = Interaction.create({
|
|
715
|
+
name: 'PostComment',
|
|
716
|
+
action: Action.create({ name: 'postComment' }),
|
|
717
|
+
conditions: Conditions.create({
|
|
718
|
+
content: BoolExp.atom(VerifiedEmail).and(BoolExp.atom(NotBanned))
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// Test various user states
|
|
723
|
+
const verifiedUser = await system.storage.create('User', {
|
|
724
|
+
emailVerified: true,
|
|
725
|
+
banned: false
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
const unverifiedUser = await system.storage.create('User', {
|
|
729
|
+
emailVerified: false,
|
|
730
|
+
banned: false
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
const bannedUser = await system.storage.create('User', {
|
|
734
|
+
emailVerified: true,
|
|
735
|
+
banned: true
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
// Only verified, non-banned user can post
|
|
739
|
+
const result1 = await controller.callInteraction('PostComment', {
|
|
740
|
+
user: verifiedUser
|
|
741
|
+
})
|
|
742
|
+
expect(result1.error).toBeUndefined()
|
|
743
|
+
|
|
744
|
+
const result2 = await controller.callInteraction('PostComment', {
|
|
745
|
+
user: unverifiedUser
|
|
746
|
+
})
|
|
747
|
+
expect(result2.error).toBeDefined()
|
|
748
|
+
|
|
749
|
+
const result3 = await controller.callInteraction('PostComment', {
|
|
750
|
+
user: bannedUser
|
|
751
|
+
})
|
|
752
|
+
expect(result3.error).toBeDefined()
|
|
753
|
+
})
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### 6. Test Conditional Updates
|
|
757
|
+
```typescript
|
|
758
|
+
test('conditional state updates', async () => {
|
|
759
|
+
// User can only delete if not deleted
|
|
760
|
+
const NotDeleted = Condition.create({
|
|
761
|
+
name: 'NotDeleted',
|
|
762
|
+
content: async function(this: Controller, event) {
|
|
763
|
+
const styleId = event.payload?.style?.id
|
|
764
|
+
if (!styleId) return false
|
|
765
|
+
|
|
766
|
+
const style = await this.system.storage.findOne('Style',
|
|
767
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] }),
|
|
768
|
+
undefined,
|
|
769
|
+
['isDeleted']
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
return style && !style.isDeleted
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const DeleteStyle = Interaction.create({
|
|
777
|
+
name: 'DeleteStyle',
|
|
778
|
+
action: Action.create({ name: 'deleteStyle' }),
|
|
779
|
+
conditions: Conditions.create({
|
|
780
|
+
content: BoolExp.atom(AdminRole).and(BoolExp.atom(NotDeleted))
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
const admin = await system.storage.create('User', { role: 'admin' })
|
|
785
|
+
const style = await system.storage.create('Style', {
|
|
786
|
+
label: 'Test',
|
|
787
|
+
isDeleted: false
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
// First delete succeeds
|
|
791
|
+
const result1 = await controller.callInteraction('DeleteStyle', {
|
|
792
|
+
user: admin,
|
|
793
|
+
payload: { style: { id: style.id } }
|
|
794
|
+
})
|
|
795
|
+
expect(result1.error).toBeUndefined()
|
|
796
|
+
|
|
797
|
+
// Update style to deleted
|
|
798
|
+
await system.storage.update('Style', style.id, { isDeleted: true })
|
|
799
|
+
|
|
800
|
+
// Second delete fails
|
|
801
|
+
const result2 = await controller.callInteraction('DeleteStyle', {
|
|
802
|
+
user: admin,
|
|
803
|
+
payload: { style: { id: style.id } }
|
|
804
|
+
})
|
|
805
|
+
expect(result2.error).toBeDefined()
|
|
806
|
+
})
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
## Testing Checklist
|
|
810
|
+
- [ ] Test all user roles (admin, operator, viewer, etc.)
|
|
811
|
+
- [ ] Test allowed and denied scenarios
|
|
812
|
+
- [ ] Test with missing/invalid payload data
|
|
813
|
+
- [ ] Test resource state conditions
|
|
814
|
+
- [ ] Test combined permissions (AND/OR logic)
|
|
815
|
+
- [ ] Test edge cases (null user, non-existent resources)
|
|
816
|
+
- [ ] Verify error types are 'condition check failed'
|
|
817
|
+
- [ ] Verify specific failed condition name with `error.error.data.name`
|
|
818
|
+
- [ ] Test custom error messages if used
|
|
819
|
+
- [ ] Cover all branches in condition logic
|
|
820
|
+
- [ ] Test time/state dependent conditions
|
|
821
|
+
|
|
822
|
+
## Common Testing Mistakes
|
|
823
|
+
|
|
824
|
+
### 1. Missing attributeQuery
|
|
825
|
+
```typescript
|
|
826
|
+
// ❌ WRONG: Without attributeQuery, only returns { id }
|
|
827
|
+
const style = await system.storage.findOne('Style',
|
|
828
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] })
|
|
829
|
+
)
|
|
830
|
+
// style.creator is undefined!
|
|
831
|
+
|
|
832
|
+
// ✅ CORRECT: Specify needed fields
|
|
833
|
+
const style = await system.storage.findOne('Style',
|
|
834
|
+
MatchExp.atom({ key: 'id', value: ['=', styleId] }),
|
|
835
|
+
undefined,
|
|
836
|
+
['id', 'creator', 'status'] // Include all needed fields
|
|
837
|
+
)
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
### 2. Wrong Error Expectations
|
|
841
|
+
```typescript
|
|
842
|
+
// ❌ WRONG: Expecting wrong error type or incomplete verification
|
|
843
|
+
expect((result.error as ConditionError).type).toBe('no permission')
|
|
844
|
+
|
|
845
|
+
// ❌ INCOMPLETE: Only checking error type
|
|
846
|
+
expect(result.error).toBeDefined()
|
|
847
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
848
|
+
|
|
849
|
+
// ✅ CORRECT: Complete error verification including which condition failed
|
|
850
|
+
expect(result.error).toBeDefined()
|
|
851
|
+
expect((result.error as ConditionError).type).toBe('condition check failed')
|
|
852
|
+
expect((result.error as ConditionError).error.data.name).toBe('AdminRole') // Verify specific condition
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
### 3. Incomplete Test Coverage
|
|
856
|
+
```typescript
|
|
857
|
+
// ❌ WRONG: Only testing happy path
|
|
858
|
+
test('admin can delete', async () => {
|
|
859
|
+
// Only tests admin success
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
// ✅ CORRECT: Test all scenarios
|
|
863
|
+
test('delete permissions', async () => {
|
|
864
|
+
// Test admin: allowed
|
|
865
|
+
// Test operator: denied
|
|
866
|
+
// Test viewer: denied
|
|
867
|
+
// Test null user: denied
|
|
868
|
+
// Test with deleted style: denied
|
|
869
|
+
})
|
|
870
|
+
```
|