@api-client/core 0.19.41 → 0.19.42

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.
Files changed (64) hide show
  1. package/build/src/index.d.ts +1 -1
  2. package/build/src/index.d.ts.map +1 -1
  3. package/build/src/index.js.map +1 -1
  4. package/build/src/modeling/RuntimeApiModel.d.ts +20 -0
  5. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
  6. package/build/src/modeling/RuntimeApiModel.js +133 -0
  7. package/build/src/modeling/RuntimeApiModel.js.map +1 -1
  8. package/build/src/modeling/index.d.ts +1 -0
  9. package/build/src/modeling/index.d.ts.map +1 -1
  10. package/build/src/modeling/index.js.map +1 -1
  11. package/build/src/modeling/rules/AccessRule.d.ts +40 -1
  12. package/build/src/modeling/rules/AccessRule.d.ts.map +1 -1
  13. package/build/src/modeling/rules/AccessRule.js +44 -2
  14. package/build/src/modeling/rules/AccessRule.js.map +1 -1
  15. package/build/src/modeling/rules/AllowAuthenticated.d.ts.map +1 -1
  16. package/build/src/modeling/rules/AllowAuthenticated.js +9 -2
  17. package/build/src/modeling/rules/AllowAuthenticated.js.map +1 -1
  18. package/build/src/modeling/rules/AllowPublic.d.ts.map +1 -1
  19. package/build/src/modeling/rules/AllowPublic.js +9 -2
  20. package/build/src/modeling/rules/AllowPublic.js.map +1 -1
  21. package/build/src/modeling/rules/LifecycleStatus.d.ts +36 -0
  22. package/build/src/modeling/rules/LifecycleStatus.d.ts.map +1 -0
  23. package/build/src/modeling/rules/LifecycleStatus.js +60 -0
  24. package/build/src/modeling/rules/LifecycleStatus.js.map +1 -0
  25. package/build/src/modeling/rules/MatchEmailDomain.d.ts.map +1 -1
  26. package/build/src/modeling/rules/MatchEmailDomain.js +9 -2
  27. package/build/src/modeling/rules/MatchEmailDomain.js.map +1 -1
  28. package/build/src/modeling/rules/MatchResourceAttribute.d.ts +38 -0
  29. package/build/src/modeling/rules/MatchResourceAttribute.d.ts.map +1 -0
  30. package/build/src/modeling/rules/MatchResourceAttribute.js +68 -0
  31. package/build/src/modeling/rules/MatchResourceAttribute.js.map +1 -0
  32. package/build/src/modeling/rules/MatchResourceOwner.d.ts.map +1 -1
  33. package/build/src/modeling/rules/MatchResourceOwner.js +8 -2
  34. package/build/src/modeling/rules/MatchResourceOwner.js.map +1 -1
  35. package/build/src/modeling/rules/MatchUserProperty.d.ts.map +1 -1
  36. package/build/src/modeling/rules/MatchUserProperty.js +9 -2
  37. package/build/src/modeling/rules/MatchUserProperty.js.map +1 -1
  38. package/build/src/modeling/rules/MatchUserRole.d.ts.map +1 -1
  39. package/build/src/modeling/rules/MatchUserRole.js +9 -2
  40. package/build/src/modeling/rules/MatchUserRole.js.map +1 -1
  41. package/build/src/modeling/rules/index.d.ts +8 -6
  42. package/build/src/modeling/rules/index.d.ts.map +1 -1
  43. package/build/src/modeling/rules/index.js +8 -2
  44. package/build/src/modeling/rules/index.js.map +1 -1
  45. package/build/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/modeling/RuntimeApiModel.ts +166 -2
  48. package/src/modeling/rules/AccessRule.ts +70 -2
  49. package/src/modeling/rules/AllowAuthenticated.ts +13 -2
  50. package/src/modeling/rules/AllowPublic.ts +13 -2
  51. package/src/modeling/rules/LifecycleStatus.ts +71 -0
  52. package/src/modeling/rules/MatchEmailDomain.ts +13 -2
  53. package/src/modeling/rules/MatchResourceAttribute.ts +82 -0
  54. package/src/modeling/rules/MatchResourceOwner.ts +12 -2
  55. package/src/modeling/rules/MatchUserProperty.ts +13 -2
  56. package/src/modeling/rules/MatchUserRole.ts +13 -2
  57. package/tests/unit/modeling/RuntimeApiModel.spec.ts +189 -1
  58. package/tests/unit/modeling/actions/Action.spec.ts +2 -2
  59. package/tests/unit/modeling/actions/CreateAction.spec.ts +1 -1
  60. package/tests/unit/modeling/actions/ReadAction.spec.ts +2 -2
  61. package/tests/unit/modeling/exposed_entity.spec.ts +1 -1
  62. package/tests/unit/modeling/rules/AccessRule.spec.ts +5 -5
  63. package/tests/unit/modeling/rules/LifecycleStatus.spec.ts +55 -0
  64. package/tests/unit/modeling/rules/MatchResourceAttribute.spec.ts +66 -0
@@ -1,8 +1,10 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
1
2
  import { test } from '@japa/runner'
2
3
  import { ApiModel } from '../../../src/modeling/ApiModel.js'
3
- import { RuntimeApiModel, type RuntimeApiModelSchema } from '../../../src/modeling/RuntimeApiModel.js'
4
+ import { RuleEvaluator, RuntimeApiModel, type RuntimeApiModelSchema } from '../../../src/modeling/RuntimeApiModel.js'
4
5
  import { DataDomain } from '../../../src/modeling/DataDomain.js'
5
6
  import { SemanticType } from '../../../src/modeling/Semantics.js'
7
+ import { AccessRule, AccessRuleExecutionPhase } from '../../../src/modeling/index.js'
6
8
 
7
9
  test.group('RuntimeApiModel', () => {
8
10
  test('initializes from schema', ({ assert }) => {
@@ -150,4 +152,190 @@ test.group('RuntimeApiModel', () => {
150
152
  assert.equal(runtimeModel.cachedProperties.role?.key, roleProp.key)
151
153
  assert.equal(runtimeModel.cachedProperties.username?.key, usernameProp.key)
152
154
  }).tags(['@modeling', '@runtime'])
155
+
156
+ test('evaluateAccess - rejects if any mandatory rule fails', async ({ assert }) => {
157
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
158
+ const baseModel = new ApiModel(
159
+ { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
160
+ domain
161
+ )
162
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
163
+
164
+ const mockAction = {
165
+ entity: { accessRule: [], parent: undefined },
166
+ action: { kind: 'read', accessRule: [] },
167
+ } as any
168
+
169
+ const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<boolean> => false // Mandatory rule fails
170
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
171
+ assert.isFalse(result)
172
+ }).tags(['@modeling', '@runtime', '@authorization'])
173
+
174
+ test('evaluateAccess - rejects by default if no rules hit', async ({ assert }) => {
175
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
176
+ const baseModel = new ApiModel({ key: 'api-1' }, domain)
177
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
178
+
179
+ const mockAction = {
180
+ entity: {
181
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
182
+ parent: undefined,
183
+ },
184
+ action: { kind: 'read', accessRule: [] },
185
+ } as any
186
+
187
+ const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<undefined> => undefined // No rules match
188
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
189
+ assert.isFalse(result)
190
+ }).tags(['@modeling', '@runtime', '@authorization'])
191
+
192
+ test('evaluateAccess - evaluates from most specific to most general in permission phase', async ({ assert }) => {
193
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
194
+ const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
195
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
196
+
197
+ const mockAction = {
198
+ entity: {
199
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
200
+ parent: undefined,
201
+ },
202
+ action: {
203
+ kind: 'read',
204
+ accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
205
+ },
206
+ } as any
207
+
208
+ const evaluatedOrder: string[] = []
209
+ const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<undefined> => {
210
+ evaluatedOrder.push(rule.type)
211
+ return undefined // Continue to next rule
212
+ }
213
+
214
+ await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
215
+ assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic', 'allowAuthenticated'])
216
+ }).tags(['@modeling', '@runtime', '@authorization'])
217
+
218
+ test('evaluateAccess - short-circuits on explicit allow or deny in permission phase', async ({ assert }) => {
219
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
220
+ const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
221
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
222
+
223
+ const mockAction = {
224
+ entity: {
225
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
226
+ parent: undefined,
227
+ },
228
+ action: {
229
+ kind: 'read',
230
+ accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
231
+ },
232
+ } as any
233
+
234
+ const evaluatedOrder: string[] = []
235
+ const evaluator = async (rule: any) => {
236
+ evaluatedOrder.push(rule.type)
237
+ if (rule.type === 'allowPublic') return true
238
+ return undefined
239
+ }
240
+
241
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
242
+ assert.isTrue(result)
243
+ // Should not reach the api-rule
244
+ assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic'])
245
+ }).tags(['@modeling', '@runtime', '@authorization'])
246
+
247
+ test('evaluateAccess - passes mandatory phase and uses permission phase result', async ({ assert }) => {
248
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
249
+ const baseModel = new ApiModel(
250
+ { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
251
+ domain
252
+ )
253
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
254
+
255
+ const mockAction = {
256
+ entity: {
257
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
258
+ parent: undefined,
259
+ },
260
+ action: { kind: 'read', accessRule: [] },
261
+ } as any
262
+
263
+ const evaluator = async (rule: any) => {
264
+ if (rule.mandatory) return true // passes mandatory check
265
+ if (rule.type === 'allowPublic') return false // explicitly denies
266
+ return undefined
267
+ }
268
+
269
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
270
+ assert.isFalse(result) // Denied by permission rule
271
+ }).tags(['@modeling', '@runtime', '@authorization'])
272
+
273
+ test('evaluateAccess - shadows parent rules of the same type', async ({ assert }) => {
274
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
275
+ const model = domain.addModel({ key: 'mod-1' })
276
+ const entity = model.addEntity({ key: 'ent-1' })
277
+
278
+ // Create base model with exposing and actions directly in schema
279
+ const baseModel = new ApiModel(
280
+ {
281
+ key: 'api-1',
282
+ accessRule: [{ type: 'allowAuthenticated' }, { type: 'matchEmailDomain' }],
283
+ exposes: [
284
+ {
285
+ key: entity.key,
286
+ entity: { key: entity.key },
287
+ accessRule: [
288
+ { type: 'allowPublic', mandatory: false },
289
+ { type: 'matchEmailDomain', mandatory: true },
290
+ ],
291
+ actions: [
292
+ {
293
+ kind: 'read',
294
+ accessRule: [{ type: 'allowAuthenticated', mandatory: true }],
295
+ },
296
+ ],
297
+ hasCollection: true,
298
+ kind: 'Core#ExposedEntity',
299
+ resourcePath: '',
300
+ },
301
+ ],
302
+ },
303
+ domain
304
+ )
305
+
306
+ // Hierarchy will be:
307
+ // Action: allowAuthenticated (mandatory)
308
+ // Entity: allowPublic, matchEmailDomain (mandatory)
309
+ // API: allowAuthenticated, matchEmailDomain
310
+
311
+ // Effective Rules (with shadowing):
312
+ // Action's allowAuthenticated (mandatory) shadows API's allowAuthenticated
313
+ // Entity's matchEmailDomain (mandatory) shadows API's matchEmailDomain
314
+ // Entity's allowPublic is kept
315
+
316
+ // Mandatory: allowAuthenticated, matchEmailDomain
317
+ // Permission: allowPublic
318
+
319
+ const schema: RuntimeApiModelSchema = {
320
+ ...baseModel.toJSON(),
321
+ routingMap: {
322
+ GET: [{ path: '/test', lookup: { exposedEntityKey: entity.key, actionKind: 'read' } }],
323
+ },
324
+ }
325
+
326
+ const runtimeModel = new RuntimeApiModel(schema, domain.toJSON())
327
+ const mockAction = runtimeModel.lookupAction('GET', '/test')!
328
+
329
+ const evaluatedOrder: string[] = []
330
+ const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<boolean | undefined> => {
331
+ evaluatedOrder.push(`${rule.type}${rule.mandatory ? '-mandatory' : ''}`)
332
+ if (rule.mandatory) return true
333
+ return undefined
334
+ }
335
+
336
+ await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
337
+
338
+ // Check that we only evaluate the shadowed rules, exactly once per phase
339
+ assert.deepEqual(evaluatedOrder, ['allowAuthenticated-mandatory', 'matchEmailDomain-mandatory', 'allowPublic'])
340
+ }).tags(['@modeling', '@runtime', '@authorization'])
153
341
  })
@@ -60,7 +60,7 @@ test.group('Action', () => {
60
60
  notified = true
61
61
  })
62
62
 
63
- action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
63
+ action.accessRule = [new AccessRule(mockExposedEntity(), {}, { type: 'allowPublic' })]
64
64
  await Promise.resolve()
65
65
  assert.isTrue(notified)
66
66
  }).tags(['@modeling', '@action', '@observed'])
@@ -111,7 +111,7 @@ test.group('Action', () => {
111
111
 
112
112
  test('getAllRules() aggregates rules from action and parent', ({ assert }) => {
113
113
  const parent = {
114
- getAllRules: () => [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })],
114
+ getAllRules: () => [new AccessRule(mockExposedEntity(), {}, { type: 'allowPublic' })],
115
115
  notifyChange: () => {},
116
116
  } as unknown as ExposedEntity
117
117
 
@@ -59,7 +59,7 @@ test.group('CreateAction', () => {
59
59
  })
60
60
 
61
61
  // Modify inherited property
62
- action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
62
+ action.accessRule = [new AccessRule(mockExposedEntity(), {}, { type: 'allowPublic' })]
63
63
  await Promise.resolve()
64
64
  assert.isTrue(notified)
65
65
  }).tags(['@modeling', '@action', '@create-action', '@observed'])
@@ -59,7 +59,7 @@ test.group('ReadAction', () => {
59
59
  })
60
60
 
61
61
  // Modify inherited property
62
- action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
62
+ action.accessRule = [new AccessRule(mockExposedEntity(), {}, { type: 'allowPublic' })]
63
63
  await Promise.resolve()
64
64
  assert.isTrue(notified)
65
65
  }).tags(['@modeling', '@action', '@read-action', '@observed'])
@@ -71,7 +71,7 @@ test.group('ReadAction', () => {
71
71
  notified = true
72
72
  })
73
73
 
74
- action.accessRule.push(new AccessRule(mockExposedEntity(), { type: 'allowPublic' }))
74
+ action.accessRule.push(new AccessRule(mockExposedEntity(), {}, { type: 'allowPublic' }))
75
75
  await Promise.resolve()
76
76
  assert.isTrue(notified)
77
77
  }).tags(['@modeling', '@action', '@read-action', '@observed'])
@@ -308,7 +308,7 @@ test.group('ExposedEntity', () => {
308
308
 
309
309
  test('getAllRules() aggregates rules from entity, parent, and API', ({ assert }) => {
310
310
  const model = new ApiModel()
311
- model.accessRule = [new AccessRule(model, { type: 'allowPublic' })]
311
+ model.accessRule = [new AccessRule(model, {}, { type: 'allowPublic' })]
312
312
 
313
313
  const rootEx = new ExposedEntity(model, {
314
314
  key: 'root',
@@ -4,24 +4,24 @@ import { mockExposedEntity } from '../actions/helpers.js'
4
4
 
5
5
  test.group('AccessRule', () => {
6
6
  test('initializes with default values', ({ assert }) => {
7
- const rule = new AccessRule(mockExposedEntity())
7
+ const rule = new AccessRule(mockExposedEntity(), {})
8
8
  assert.equal(rule.type, '')
9
9
  }).tags(['@modeling', '@access-rule'])
10
10
 
11
11
  test('initializes with provided values', ({ assert }) => {
12
12
  const schema: AccessRuleSchema = { type: 'public' }
13
- const rule = new AccessRule(mockExposedEntity(), schema)
13
+ const rule = new AccessRule(mockExposedEntity(), {}, schema)
14
14
  assert.equal(rule.type, 'public')
15
15
  }).tags(['@modeling', '@access-rule'])
16
16
 
17
17
  test('serializes to JSON', ({ assert }) => {
18
- const rule = new AccessRule(mockExposedEntity(), { type: 'authenticated' })
18
+ const rule = new AccessRule(mockExposedEntity(), {}, { type: 'authenticated' })
19
19
  const json = rule.toJSON()
20
20
  assert.deepEqual(json, { type: 'authenticated' })
21
21
  }).tags(['@modeling', '@access-rule'])
22
22
 
23
23
  test('notifies change', async ({ assert }) => {
24
- const rule = new AccessRule(mockExposedEntity(), { type: 'public' })
24
+ const rule = new AccessRule(mockExposedEntity(), {}, { type: 'public' })
25
25
  let notified = false
26
26
  rule.addEventListener('change', () => {
27
27
  notified = true
@@ -32,7 +32,7 @@ test.group('AccessRule', () => {
32
32
  }).tags(['@modeling', '@access-rule'])
33
33
 
34
34
  test('toJSON returns safe copy (immutability)', ({ assert }) => {
35
- const rule = new AccessRule(mockExposedEntity(), { type: 'public' })
35
+ const rule = new AccessRule(mockExposedEntity(), {}, { type: 'public' })
36
36
  const json = rule.toJSON()
37
37
 
38
38
  // Modify JSON (simulate runtime mutation)
@@ -0,0 +1,55 @@
1
+ import { test } from '@japa/runner'
2
+ import { LifecycleStatusAccessRule } from '../../../../src/modeling/rules/LifecycleStatus.js'
3
+ import { mockExposedEntity } from '../actions/helpers.js'
4
+
5
+ test.group('LifecycleStatusAccessRule', () => {
6
+ test('initializes with default values', ({ assert }) => {
7
+ const rule = new LifecycleStatusAccessRule(mockExposedEntity())
8
+ assert.equal(rule.type, 'lifecycleStatus')
9
+ assert.deepEqual(rule.allowedStatuses, [])
10
+ assert.deepEqual(rule.deniedStatuses, [])
11
+ }).tags(['@modeling', '@rule', '@lifecycle-status'])
12
+
13
+ test('initializes with provided values', ({ assert }) => {
14
+ const rule = new LifecycleStatusAccessRule(mockExposedEntity(), {
15
+ allowedStatuses: ['published'],
16
+ deniedStatuses: ['archived'],
17
+ })
18
+
19
+ assert.equal(rule.type, 'lifecycleStatus')
20
+ assert.deepEqual(rule.allowedStatuses, ['published'])
21
+ assert.deepEqual(rule.deniedStatuses, ['archived'])
22
+ }).tags(['@modeling', '@rule', '@lifecycle-status'])
23
+
24
+ test('toJSON returns valid schema', ({ assert }) => {
25
+ const rule = new LifecycleStatusAccessRule(mockExposedEntity(), {
26
+ allowedStatuses: ['active', 'pending'],
27
+ })
28
+ const json = rule.toJSON()
29
+
30
+ assert.equal(json.type, 'lifecycleStatus')
31
+ assert.deepEqual(json.allowedStatuses, ['active', 'pending'])
32
+ assert.isUndefined(json.deniedStatuses)
33
+ }).tags(['@modeling', '@rule', '@lifecycle-status', '@serialization'])
34
+
35
+ test('toJSON omits empty arrays', ({ assert }) => {
36
+ const rule = new LifecycleStatusAccessRule(mockExposedEntity())
37
+ const json = rule.toJSON()
38
+
39
+ assert.equal(json.type, 'lifecycleStatus')
40
+ assert.isUndefined(json.allowedStatuses)
41
+ assert.isUndefined(json.deniedStatuses)
42
+ }).tags(['@modeling', '@rule', '@lifecycle-status', '@serialization'])
43
+
44
+ test('notifies change when allowedStatuses changes', async ({ assert }) => {
45
+ const rule = new LifecycleStatusAccessRule(mockExposedEntity())
46
+ let notified = false
47
+ rule.addEventListener('change', () => {
48
+ notified = true
49
+ })
50
+
51
+ rule.allowedStatuses.push('draft')
52
+ await Promise.resolve()
53
+ assert.isTrue(notified)
54
+ }).tags(['@modeling', '@rule', '@lifecycle-status', '@observed'])
55
+ })
@@ -0,0 +1,66 @@
1
+ import { test } from '@japa/runner'
2
+ import { MatchResourceAttributeAccessRule } from '../../../../src/modeling/rules/MatchResourceAttribute.js'
3
+ import { mockExposedEntity } from '../actions/helpers.js'
4
+
5
+ test.group('MatchResourceAttributeAccessRule', () => {
6
+ test('initializes with default values', ({ assert }) => {
7
+ const rule = new MatchResourceAttributeAccessRule(mockExposedEntity())
8
+ assert.equal(rule.type, 'matchResourceAttribute')
9
+ assert.equal(rule.attribute, '')
10
+ assert.equal(rule.value, '')
11
+ assert.equal(rule.operator, 'equal')
12
+ }).tags(['@modeling', '@rule', '@match-resource-attribute'])
13
+
14
+ test('initializes with provided values', ({ assert }) => {
15
+ const rule = new MatchResourceAttributeAccessRule(mockExposedEntity(), {
16
+ attribute: 'status',
17
+ value: 'published',
18
+ operator: 'notEqual',
19
+ })
20
+
21
+ assert.equal(rule.type, 'matchResourceAttribute')
22
+ assert.equal(rule.attribute, 'status')
23
+ assert.equal(rule.value, 'published')
24
+ assert.equal(rule.operator, 'notEqual')
25
+ }).tags(['@modeling', '@rule', '@match-resource-attribute'])
26
+
27
+ test('toJSON returns valid schema', ({ assert }) => {
28
+ const rule = new MatchResourceAttributeAccessRule(mockExposedEntity(), {
29
+ attribute: 'age',
30
+ value: 18,
31
+ operator: 'greaterThanOrEqual',
32
+ })
33
+ const json = rule.toJSON()
34
+
35
+ assert.equal(json.type, 'matchResourceAttribute')
36
+ assert.equal(json.attribute, 'age')
37
+ assert.equal(json.value, 18)
38
+ assert.equal(json.operator, 'greaterThanOrEqual')
39
+ }).tags(['@modeling', '@rule', '@match-resource-attribute', '@serialization'])
40
+
41
+ test('toJSON omits operator when it is equal', ({ assert }) => {
42
+ const rule = new MatchResourceAttributeAccessRule(mockExposedEntity(), {
43
+ attribute: 'isPublic',
44
+ value: true,
45
+ operator: 'equal',
46
+ })
47
+ const json = rule.toJSON()
48
+
49
+ assert.equal(json.type, 'matchResourceAttribute')
50
+ assert.equal(json.attribute, 'isPublic')
51
+ assert.equal(json.value, true)
52
+ assert.equal(json.operator, 'equal')
53
+ }).tags(['@modeling', '@rule', '@match-resource-attribute', '@serialization'])
54
+
55
+ test('notifies change when attribute changes', async ({ assert }) => {
56
+ const rule = new MatchResourceAttributeAccessRule(mockExposedEntity())
57
+ let notified = false
58
+ rule.addEventListener('change', () => {
59
+ notified = true
60
+ })
61
+
62
+ rule.attribute = 'visibility'
63
+ await Promise.resolve()
64
+ assert.isTrue(notified)
65
+ }).tags(['@modeling', '@rule', '@match-resource-attribute', '@observed'])
66
+ })