@api-client/core 0.19.41 → 0.20.0

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 (81) hide show
  1. package/build/src/index.d.ts +2 -1
  2. package/build/src/index.d.ts.map +1 -1
  3. package/build/src/index.js +4 -0
  4. package/build/src/index.js.map +1 -1
  5. package/build/src/modeling/RuntimeApiModel.d.ts +25 -0
  6. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
  7. package/build/src/modeling/RuntimeApiModel.js +158 -0
  8. package/build/src/modeling/RuntimeApiModel.js.map +1 -1
  9. package/build/src/modeling/helpers/runtime.d.ts +5 -0
  10. package/build/src/modeling/helpers/runtime.d.ts.map +1 -0
  11. package/build/src/modeling/helpers/runtime.js +12 -0
  12. package/build/src/modeling/helpers/runtime.js.map +1 -0
  13. package/build/src/modeling/index.d.ts +1 -0
  14. package/build/src/modeling/index.d.ts.map +1 -1
  15. package/build/src/modeling/index.js.map +1 -1
  16. package/build/src/modeling/rules/AccessRule.d.ts +40 -1
  17. package/build/src/modeling/rules/AccessRule.d.ts.map +1 -1
  18. package/build/src/modeling/rules/AccessRule.js +44 -2
  19. package/build/src/modeling/rules/AccessRule.js.map +1 -1
  20. package/build/src/modeling/rules/AllowAuthenticated.d.ts.map +1 -1
  21. package/build/src/modeling/rules/AllowAuthenticated.js +9 -2
  22. package/build/src/modeling/rules/AllowAuthenticated.js.map +1 -1
  23. package/build/src/modeling/rules/AllowPublic.d.ts.map +1 -1
  24. package/build/src/modeling/rules/AllowPublic.js +9 -2
  25. package/build/src/modeling/rules/AllowPublic.js.map +1 -1
  26. package/build/src/modeling/rules/LifecycleStatus.d.ts +36 -0
  27. package/build/src/modeling/rules/LifecycleStatus.d.ts.map +1 -0
  28. package/build/src/modeling/rules/LifecycleStatus.js +60 -0
  29. package/build/src/modeling/rules/LifecycleStatus.js.map +1 -0
  30. package/build/src/modeling/rules/MatchEmailDomain.d.ts.map +1 -1
  31. package/build/src/modeling/rules/MatchEmailDomain.js +9 -2
  32. package/build/src/modeling/rules/MatchEmailDomain.js.map +1 -1
  33. package/build/src/modeling/rules/MatchResourceAttribute.d.ts +38 -0
  34. package/build/src/modeling/rules/MatchResourceAttribute.d.ts.map +1 -0
  35. package/build/src/modeling/rules/MatchResourceAttribute.js +68 -0
  36. package/build/src/modeling/rules/MatchResourceAttribute.js.map +1 -0
  37. package/build/src/modeling/rules/MatchResourceOwner.d.ts.map +1 -1
  38. package/build/src/modeling/rules/MatchResourceOwner.js +8 -2
  39. package/build/src/modeling/rules/MatchResourceOwner.js.map +1 -1
  40. package/build/src/modeling/rules/MatchUserProperty.d.ts.map +1 -1
  41. package/build/src/modeling/rules/MatchUserProperty.js +9 -2
  42. package/build/src/modeling/rules/MatchUserProperty.js.map +1 -1
  43. package/build/src/modeling/rules/MatchUserRole.d.ts.map +1 -1
  44. package/build/src/modeling/rules/MatchUserRole.js +9 -2
  45. package/build/src/modeling/rules/MatchUserRole.js.map +1 -1
  46. package/build/src/modeling/rules/index.d.ts +8 -6
  47. package/build/src/modeling/rules/index.d.ts.map +1 -1
  48. package/build/src/modeling/rules/index.js +8 -2
  49. package/build/src/modeling/rules/index.js.map +1 -1
  50. package/build/src/modeling/types.d.ts +3 -4
  51. package/build/src/modeling/types.d.ts.map +1 -1
  52. package/build/src/modeling/types.js.map +1 -1
  53. package/build/src/modeling/validation/api_model_rules.js +1 -1
  54. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  55. package/build/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +1 -1
  57. package/src/modeling/RuntimeApiModel.ts +194 -2
  58. package/src/modeling/helpers/runtime.ts +12 -0
  59. package/src/modeling/rules/AccessRule.ts +70 -2
  60. package/src/modeling/rules/AllowAuthenticated.ts +13 -2
  61. package/src/modeling/rules/AllowPublic.ts +13 -2
  62. package/src/modeling/rules/LifecycleStatus.ts +71 -0
  63. package/src/modeling/rules/MatchEmailDomain.ts +13 -2
  64. package/src/modeling/rules/MatchResourceAttribute.ts +82 -0
  65. package/src/modeling/rules/MatchResourceOwner.ts +12 -2
  66. package/src/modeling/rules/MatchUserProperty.ts +13 -2
  67. package/src/modeling/rules/MatchUserRole.ts +13 -2
  68. package/src/modeling/types.ts +3 -4
  69. package/src/modeling/validation/api_model_rules.ts +1 -1
  70. package/tests/unit/modeling/RuntimeApiModel.spec.ts +247 -1
  71. package/tests/unit/modeling/actions/Action.spec.ts +2 -2
  72. package/tests/unit/modeling/actions/CreateAction.spec.ts +1 -1
  73. package/tests/unit/modeling/actions/ReadAction.spec.ts +2 -2
  74. package/tests/unit/modeling/api_model.spec.ts +6 -6
  75. package/tests/unit/modeling/exposed_entity.spec.ts +1 -1
  76. package/tests/unit/modeling/generators/OasGenerator.spec.ts +1 -1
  77. package/tests/unit/modeling/helpers/runtime.spec.ts +48 -0
  78. package/tests/unit/modeling/rules/AccessRule.spec.ts +5 -5
  79. package/tests/unit/modeling/rules/LifecycleStatus.spec.ts +55 -0
  80. package/tests/unit/modeling/rules/MatchResourceAttribute.spec.ts +66 -0
  81. package/tests/unit/modeling/validation/api_model_rules.spec.ts +2 -2
@@ -356,12 +356,11 @@ export interface SessionConfiguration {
356
356
  */
357
357
  secret: string
358
358
  /**
359
- * The properties from the `User` entity to be encoded into the session payload (JWT/cookie).
359
+ * The properties to be set on the session `User` object.
360
360
  * These properties become available in the `request.auth` object at runtime.
361
- *
362
- * In practice, these are the ids of the properties in the `User` entity.
361
+ * The properties are coming from the `User` entity (marked by the Semantic model).
363
362
  */
364
- properties: string[]
363
+ properties: AssociationTarget[]
365
364
  /**
366
365
  * The cookie-based session transport configuration.
367
366
  */
@@ -231,7 +231,7 @@ export function validateApiModelSecurity(model: ApiModel): ApiModelValidationIte
231
231
  break
232
232
  }
233
233
  }
234
- if (roleProp && !model.session.properties.includes(roleProp.key)) {
234
+ if (roleProp && !model.session.properties.find((p) => p.key === roleProp?.key)) {
235
235
  issues.push({
236
236
  code: createCode('API', 'MISSING_RBAC_SESSION_PROPERTY'),
237
237
  message: 'The user role must be included in the session data for permissions to work.',
@@ -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,248 @@ 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('precomputes session properties correctly', ({ assert }) => {
157
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
158
+ const modelNode = domain.addModel({ key: 'users' })
159
+ const userEntity = modelNode.addEntity({ info: { name: 'User' }, semantics: [{ id: SemanticType.User }] })
160
+ const prop1 = userEntity.addProperty({ info: { name: 'Prop1' } })
161
+ const prop2 = userEntity.addProperty({ info: { name: 'Prop2' } })
162
+
163
+ const schema = {
164
+ key: 'api-1',
165
+ info: { name: 'Test API' },
166
+ routingMap: {},
167
+ session: {
168
+ properties: [{ key: prop1.key, domain: domain.key }, { key: prop2.key }],
169
+ },
170
+ }
171
+
172
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
173
+
174
+ assert.equal(runtimeModel.sessionProperties.size, 2)
175
+ assert.equal(runtimeModel.sessionProperties.get(prop1.key)?.key, prop1.key)
176
+ assert.equal(runtimeModel.sessionProperties.get(prop2.key)?.key, prop2.key)
177
+ }).tags(['@modeling', '@runtime'])
178
+
179
+ test('throws exception if session property is missing from domain', ({ assert }) => {
180
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
181
+ const modelNode = domain.addModel({ key: 'users' })
182
+ const userEntity = modelNode.addEntity({ info: { name: 'User' }, semantics: [{ id: SemanticType.User }] })
183
+ userEntity.addProperty({ info: { name: 'Prop1' } })
184
+
185
+ const schema = {
186
+ key: 'api-1',
187
+ info: { name: 'Test API' },
188
+ routingMap: {},
189
+ session: {
190
+ properties: [{ key: 'missing-prop' }],
191
+ },
192
+ }
193
+
194
+ assert.throws(
195
+ () => new RuntimeApiModel(schema as any, domain.toJSON()),
196
+ 'Session property missing-prop not found in domain'
197
+ )
198
+ }).tags(['@modeling', '@runtime'])
199
+
200
+ test('handles missing session configuration gracefully', ({ assert }) => {
201
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
202
+
203
+ const schema = {
204
+ key: 'api-1',
205
+ info: { name: 'Test API' },
206
+ routingMap: {},
207
+ }
208
+
209
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
210
+
211
+ assert.equal(runtimeModel.sessionProperties.size, 0)
212
+ }).tags(['@modeling', '@runtime'])
213
+
214
+ test('evaluateAccess - rejects if any mandatory rule fails', async ({ assert }) => {
215
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
216
+ const baseModel = new ApiModel(
217
+ { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
218
+ domain
219
+ )
220
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
221
+
222
+ const mockAction = {
223
+ entity: { accessRule: [], parent: undefined },
224
+ action: { kind: 'read', accessRule: [] },
225
+ } as any
226
+
227
+ const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<boolean> => false // Mandatory rule fails
228
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
229
+ assert.isFalse(result)
230
+ }).tags(['@modeling', '@runtime', '@authorization'])
231
+
232
+ test('evaluateAccess - rejects by default if no rules hit', async ({ assert }) => {
233
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
234
+ const baseModel = new ApiModel({ key: 'api-1' }, domain)
235
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
236
+
237
+ const mockAction = {
238
+ entity: {
239
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
240
+ parent: undefined,
241
+ },
242
+ action: { kind: 'read', accessRule: [] },
243
+ } as any
244
+
245
+ const evaluator: RuleEvaluator = async (_rule: AccessRule): Promise<undefined> => undefined // No rules match
246
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
247
+ assert.isFalse(result)
248
+ }).tags(['@modeling', '@runtime', '@authorization'])
249
+
250
+ test('evaluateAccess - evaluates from most specific to most general in permission phase', async ({ assert }) => {
251
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
252
+ const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
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: {
261
+ kind: 'read',
262
+ accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
263
+ },
264
+ } as any
265
+
266
+ const evaluatedOrder: string[] = []
267
+ const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<undefined> => {
268
+ evaluatedOrder.push(rule.type)
269
+ return undefined // Continue to next rule
270
+ }
271
+
272
+ await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
273
+ assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic', 'allowAuthenticated'])
274
+ }).tags(['@modeling', '@runtime', '@authorization'])
275
+
276
+ test('evaluateAccess - short-circuits on explicit allow or deny in permission phase', async ({ assert }) => {
277
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
278
+ const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
279
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
280
+
281
+ const mockAction = {
282
+ entity: {
283
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
284
+ parent: undefined,
285
+ },
286
+ action: {
287
+ kind: 'read',
288
+ accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
289
+ },
290
+ } as any
291
+
292
+ const evaluatedOrder: string[] = []
293
+ const evaluator = async (rule: any) => {
294
+ evaluatedOrder.push(rule.type)
295
+ if (rule.type === 'allowPublic') return true
296
+ return undefined
297
+ }
298
+
299
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
300
+ assert.isTrue(result)
301
+ // Should not reach the api-rule
302
+ assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic'])
303
+ }).tags(['@modeling', '@runtime', '@authorization'])
304
+
305
+ test('evaluateAccess - passes mandatory phase and uses permission phase result', async ({ assert }) => {
306
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
307
+ const baseModel = new ApiModel(
308
+ { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
309
+ domain
310
+ )
311
+ const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
312
+
313
+ const mockAction = {
314
+ entity: {
315
+ accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
316
+ parent: undefined,
317
+ },
318
+ action: { kind: 'read', accessRule: [] },
319
+ } as any
320
+
321
+ const evaluator = async (rule: any) => {
322
+ if (rule.mandatory) return true // passes mandatory check
323
+ if (rule.type === 'allowPublic') return false // explicitly denies
324
+ return undefined
325
+ }
326
+
327
+ const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
328
+ assert.isFalse(result) // Denied by permission rule
329
+ }).tags(['@modeling', '@runtime', '@authorization'])
330
+
331
+ test('evaluateAccess - shadows parent rules of the same type', async ({ assert }) => {
332
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
333
+ const model = domain.addModel({ key: 'mod-1' })
334
+ const entity = model.addEntity({ key: 'ent-1' })
335
+
336
+ // Create base model with exposing and actions directly in schema
337
+ const baseModel = new ApiModel(
338
+ {
339
+ key: 'api-1',
340
+ accessRule: [{ type: 'allowAuthenticated' }, { type: 'matchEmailDomain' }],
341
+ exposes: [
342
+ {
343
+ key: entity.key,
344
+ entity: { key: entity.key },
345
+ accessRule: [
346
+ { type: 'allowPublic', mandatory: false },
347
+ { type: 'matchEmailDomain', mandatory: true },
348
+ ],
349
+ actions: [
350
+ {
351
+ kind: 'read',
352
+ accessRule: [{ type: 'allowAuthenticated', mandatory: true }],
353
+ },
354
+ ],
355
+ hasCollection: true,
356
+ kind: 'Core#ExposedEntity',
357
+ resourcePath: '',
358
+ },
359
+ ],
360
+ },
361
+ domain
362
+ )
363
+
364
+ // Hierarchy will be:
365
+ // Action: allowAuthenticated (mandatory)
366
+ // Entity: allowPublic, matchEmailDomain (mandatory)
367
+ // API: allowAuthenticated, matchEmailDomain
368
+
369
+ // Effective Rules (with shadowing):
370
+ // Action's allowAuthenticated (mandatory) shadows API's allowAuthenticated
371
+ // Entity's matchEmailDomain (mandatory) shadows API's matchEmailDomain
372
+ // Entity's allowPublic is kept
373
+
374
+ // Mandatory: allowAuthenticated, matchEmailDomain
375
+ // Permission: allowPublic
376
+
377
+ const schema: RuntimeApiModelSchema = {
378
+ ...baseModel.toJSON(),
379
+ routingMap: {
380
+ GET: [{ path: '/test', lookup: { exposedEntityKey: entity.key, actionKind: 'read' } }],
381
+ },
382
+ }
383
+
384
+ const runtimeModel = new RuntimeApiModel(schema, domain.toJSON())
385
+ const mockAction = runtimeModel.lookupAction('GET', '/test')!
386
+
387
+ const evaluatedOrder: string[] = []
388
+ const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<boolean | undefined> => {
389
+ evaluatedOrder.push(`${rule.type}${rule.mandatory ? '-mandatory' : ''}`)
390
+ if (rule.mandatory) return true
391
+ return undefined
392
+ }
393
+
394
+ await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
395
+
396
+ // Check that we only evaluate the shadowed rules, exactly once per phase
397
+ assert.deepEqual(evaluatedOrder, ['allowAuthenticated-mandatory', 'matchEmailDomain-mandatory', 'allowPublic'])
398
+ }).tags(['@modeling', '@runtime', '@authorization'])
153
399
  })
@@ -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'])
@@ -53,7 +53,7 @@ test.group('ApiModel.createSchema()', () => {
53
53
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
54
54
  authentication: { strategy: 'UsernamePassword' },
55
55
  authorization: { strategy: 'RBAC' } as RolesBasedAccessControl,
56
- session: { secret: 'secret', properties: ['email'] },
56
+ session: { secret: 'secret', properties: [{ key: 'email' }] },
57
57
  accessRule: [{ type: 'public' }],
58
58
  rateLimiting: { rules: [] },
59
59
  termsOfService: 'https://example.com/terms',
@@ -72,7 +72,7 @@ test.group('ApiModel.createSchema()', () => {
72
72
  assert.deepEqual(schema.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
73
73
  assert.deepEqual(schema.authentication, { strategy: 'UsernamePassword' })
74
74
  assert.deepEqual(schema.authorization, { strategy: 'RBAC' })
75
- assert.deepEqual(schema.session, { secret: 'secret', properties: ['email'] })
75
+ assert.deepEqual(schema.session, { secret: 'secret', properties: [{ key: 'email' }] })
76
76
  assert.deepEqual(schema.accessRule, [{ type: 'public' }])
77
77
  assert.deepEqual(schema.rateLimiting, { rules: [] })
78
78
  assert.equal(schema.termsOfService, 'https://example.com/terms')
@@ -132,7 +132,7 @@ test.group('ApiModel.constructor()', () => {
132
132
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
133
133
  authentication: { strategy: 'UsernamePassword' },
134
134
  authorization: { strategy: 'RBAC' } as RolesBasedAccessControl,
135
- session: { secret: 'secret', properties: ['email'] },
135
+ session: { secret: 'secret', properties: [{ key: 'email' }] },
136
136
  accessRule: [{ type: 'allowPublic' }],
137
137
  rateLimiting: { rules: [] },
138
138
  termsOfService: 'https://example.com/terms',
@@ -150,7 +150,7 @@ test.group('ApiModel.constructor()', () => {
150
150
  assert.deepEqual(model.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
151
151
  assert.deepEqual(model.authentication, { strategy: 'UsernamePassword' })
152
152
  assert.deepEqual(model.authorization, { strategy: 'RBAC' })
153
- assert.deepEqual(model.session, { secret: 'secret', properties: ['email'] })
153
+ assert.deepEqual(model.session, { secret: 'secret', properties: [{ key: 'email' }] })
154
154
  assert.deepEqual(model.accessRule, [new AllowPublicAccessRule(model)])
155
155
  assert.deepEqual(model.rateLimiting, new RateLimitingConfiguration())
156
156
  assert.equal(model.termsOfService, 'https://example.com/terms')
@@ -289,7 +289,7 @@ test.group('ApiModel.toJSON()', () => {
289
289
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
290
290
  authentication: { strategy: 'UsernamePassword' },
291
291
  authorization: { strategy: 'RBAC' } as RolesBasedAccessControl,
292
- session: { secret: 'secret', properties: ['email'] },
292
+ session: { secret: 'secret', properties: [{ key: 'email' }] },
293
293
  accessRule: [{ type: 'allowPublic' }],
294
294
  rateLimiting: { rules: [] },
295
295
  termsOfService: 'https://example.com/terms',
@@ -308,7 +308,7 @@ test.group('ApiModel.toJSON()', () => {
308
308
  assert.deepEqual(json.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
309
309
  assert.deepEqual(json.authentication, { strategy: 'UsernamePassword' })
310
310
  assert.deepEqual(json.authorization, { strategy: 'RBAC' })
311
- assert.deepEqual(json.session, { secret: 'secret', properties: ['email'] })
311
+ assert.deepEqual(json.session, { secret: 'secret', properties: [{ key: 'email' }] })
312
312
  assert.deepEqual(json.accessRule, [{ type: 'allowPublic' }])
313
313
  assert.deepEqual(json.rateLimiting, { rules: [] })
314
314
  assert.equal(json.termsOfService, 'https://example.com/terms')
@@ -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',
@@ -129,7 +129,7 @@ test.group('OasGenerator', (group) => {
129
129
  }
130
130
  api.accessRule = [new AllowAuthenticatedAccessRule(api)]
131
131
  api.session = {
132
- properties: [email.key],
132
+ properties: [{ key: email.key }],
133
133
  secret: 'test-secret',
134
134
  cookie: {
135
135
  enabled: true,
@@ -0,0 +1,48 @@
1
+ import { test } from '@japa/runner'
2
+ import { truncateIdentifier } from '../../../../src/modeling/helpers/runtime.js'
3
+
4
+ test.group('modeling / helpers / runtime', () => {
5
+ test('returns the original identifier if it is exactly 63 characters long', ({ assert }) => {
6
+ const input = 'a'.repeat(63)
7
+ const result = truncateIdentifier(input)
8
+ assert.equal(result, input)
9
+ assert.equal(result.length, 63)
10
+ }).tags(['@modeling', '@helpers'])
11
+
12
+ test('returns the original identifier if it is less than 63 characters long', ({ assert }) => {
13
+ const input = 'short_identifier'
14
+ const result = truncateIdentifier(input)
15
+ assert.equal(result, input)
16
+ assert.equal(result.length, 16)
17
+ }).tags(['@modeling', '@helpers'])
18
+
19
+ test('truncates identifier to 63 characters if it is longer', ({ assert }) => {
20
+ const input = 'a'.repeat(64)
21
+ const result = truncateIdentifier(input)
22
+ assert.equal(result.length, 63)
23
+ assert.notEqual(result, input)
24
+ }).tags(['@modeling', '@helpers'])
25
+
26
+ test('generates deterministic hash for the same long identifier', ({ assert }) => {
27
+ const input = 'very_long_identifier_that_exceeds_the_limit_of_63_characters_which_is_too_long'
28
+ const result1 = truncateIdentifier(input)
29
+ const result2 = truncateIdentifier(input)
30
+ assert.equal(result1, result2)
31
+ }).tags(['@modeling', '@helpers'])
32
+
33
+ test('generates different hashes for different long identifiers', ({ assert }) => {
34
+ const input1 = 'very_long_identifier_that_exceeds_the_limit_of_63_characters_which_is_too_long_1'
35
+ const input2 = 'very_long_identifier_that_exceeds_the_limit_of_63_characters_which_is_too_long_2'
36
+ const result1 = truncateIdentifier(input1)
37
+ const result2 = truncateIdentifier(input2)
38
+ assert.notEqual(result1, result2)
39
+ }).tags(['@modeling', '@helpers'])
40
+
41
+ test('truncates keeping the first 54 characters', ({ assert }) => {
42
+ const prefix = 'this_is_exactly_54_characters_long_string_prefix_hello'
43
+ const input = `${prefix}_and_some_more_characters_to_exceed_63`
44
+ const result = truncateIdentifier(input)
45
+ assert.isTrue(result.startsWith(`${prefix}_`))
46
+ assert.equal(result.length, 63)
47
+ }).tags(['@modeling', '@helpers'])
48
+ })
@@ -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
+ })
@@ -67,7 +67,7 @@ test.group('ApiModel Validation', () => {
67
67
  authorization: { strategy: 'RBAC' } as RolesBasedAccessControl,
68
68
  session: {
69
69
  secret: 'super-secret',
70
- properties: ['id', 'role'],
70
+ properties: [{ key: 'id' }, { key: 'role' }],
71
71
  cookie: {
72
72
  enabled: true,
73
73
  kind: 'cookie',
@@ -401,7 +401,7 @@ test.group('ApiModel Validation', () => {
401
401
  authorization: { strategy: 'RBAC' } as RolesBasedAccessControl,
402
402
  session: {
403
403
  secret: 'super-secret',
404
- properties: ['id', 'role'],
404
+ properties: [{ key: 'id' }, { key: 'role' }],
405
405
  cookie: {
406
406
  enabled: true,
407
407
  kind: 'cookie',