@kysera/rls 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +819 -3
- package/dist/index.d.ts +2824 -8
- package/dist/index.js +2451 -1
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/{types-Dowjd6zG.d.ts → types-CyqksFKU.d.ts} +72 -1
- package/package.json +6 -6
- package/src/audit/index.ts +25 -0
- package/src/audit/logger.ts +465 -0
- package/src/audit/types.ts +625 -0
- package/src/composition/builder.ts +556 -0
- package/src/composition/index.ts +43 -0
- package/src/composition/types.ts +415 -0
- package/src/field-access/index.ts +38 -0
- package/src/field-access/processor.ts +442 -0
- package/src/field-access/registry.ts +259 -0
- package/src/field-access/types.ts +453 -0
- package/src/index.ts +180 -2
- package/src/policy/builder.ts +187 -10
- package/src/policy/types.ts +84 -0
- package/src/rebac/index.ts +30 -0
- package/src/rebac/registry.ts +303 -0
- package/src/rebac/transformer.ts +391 -0
- package/src/rebac/types.ts +412 -0
- package/src/resolvers/index.ts +30 -0
- package/src/resolvers/manager.ts +507 -0
- package/src/resolvers/types.ts +447 -0
- package/src/testing/index.ts +554 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy Testing Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides tools for unit testing RLS policies without a database.
|
|
5
|
+
*
|
|
6
|
+
* @module @kysera/rls/testing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
RLSSchema,
|
|
11
|
+
PolicyEvaluationContext,
|
|
12
|
+
Operation,
|
|
13
|
+
RLSAuthContext,
|
|
14
|
+
CompiledPolicy
|
|
15
|
+
} from '../policy/types.js'
|
|
16
|
+
import { PolicyRegistry } from '../policy/registry.js'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result of policy evaluation
|
|
24
|
+
*/
|
|
25
|
+
export interface PolicyEvaluationResult {
|
|
26
|
+
/**
|
|
27
|
+
* Whether the operation is allowed
|
|
28
|
+
*/
|
|
29
|
+
allowed: boolean
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Name of the policy that made the decision
|
|
33
|
+
*/
|
|
34
|
+
policyName?: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Type of decision
|
|
38
|
+
*/
|
|
39
|
+
decisionType: 'allow' | 'deny' | 'default'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reason for the decision
|
|
43
|
+
*/
|
|
44
|
+
reason?: string
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* All policies that were evaluated
|
|
48
|
+
*/
|
|
49
|
+
evaluatedPolicies: {
|
|
50
|
+
name: string
|
|
51
|
+
type: 'allow' | 'deny' | 'validate'
|
|
52
|
+
result: boolean
|
|
53
|
+
}[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Result of filter evaluation
|
|
58
|
+
*/
|
|
59
|
+
export interface FilterEvaluationResult {
|
|
60
|
+
/**
|
|
61
|
+
* Generated filter conditions
|
|
62
|
+
*/
|
|
63
|
+
conditions: Record<string, unknown>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Names of all filters applied
|
|
67
|
+
*/
|
|
68
|
+
appliedFilters: string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Test context for policy evaluation
|
|
73
|
+
*/
|
|
74
|
+
export interface TestContext<TRow = Record<string, unknown>> {
|
|
75
|
+
/**
|
|
76
|
+
* Auth context
|
|
77
|
+
*/
|
|
78
|
+
auth: RLSAuthContext
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Row data (for read/update/delete operations)
|
|
82
|
+
*/
|
|
83
|
+
row?: TRow
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Mutation data (for create/update operations)
|
|
87
|
+
*/
|
|
88
|
+
data?: Record<string, unknown>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Additional metadata
|
|
92
|
+
*/
|
|
93
|
+
meta?: Record<string, unknown>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Policy Tester
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Policy Tester
|
|
102
|
+
*
|
|
103
|
+
* Test RLS policies without a database connection.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const tester = createPolicyTester(rlsSchema);
|
|
108
|
+
*
|
|
109
|
+
* describe('Post RLS Policies', () => {
|
|
110
|
+
* it('should allow owner to update their post', async () => {
|
|
111
|
+
* const result = await tester.evaluate('posts', 'update', {
|
|
112
|
+
* auth: { userId: 'user-1', roles: ['user'] },
|
|
113
|
+
* row: { id: 'post-1', author_id: 'user-1', status: 'draft' }
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* expect(result.allowed).toBe(true);
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* it('should deny non-owner update', async () => {
|
|
120
|
+
* const result = await tester.evaluate('posts', 'update', {
|
|
121
|
+
* auth: { userId: 'user-2', roles: ['user'] },
|
|
122
|
+
* row: { id: 'post-1', author_id: 'user-1', status: 'draft' }
|
|
123
|
+
* });
|
|
124
|
+
*
|
|
125
|
+
* expect(result.allowed).toBe(false);
|
|
126
|
+
* expect(result.reason).toContain('not owner');
|
|
127
|
+
* });
|
|
128
|
+
*
|
|
129
|
+
* it('should apply filters correctly', async () => {
|
|
130
|
+
* const filters = await tester.getFilters('posts', 'read', {
|
|
131
|
+
* auth: { userId: 'user-1', tenantId: 'tenant-1', roles: [] }
|
|
132
|
+
* });
|
|
133
|
+
*
|
|
134
|
+
* expect(filters.conditions).toEqual({
|
|
135
|
+
* tenant_id: 'tenant-1',
|
|
136
|
+
* deleted_at: null
|
|
137
|
+
* });
|
|
138
|
+
* });
|
|
139
|
+
* });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export class PolicyTester<DB = unknown> {
|
|
143
|
+
private registry: PolicyRegistry<DB>
|
|
144
|
+
|
|
145
|
+
constructor(schema: RLSSchema<DB>) {
|
|
146
|
+
this.registry = new PolicyRegistry<DB>(schema)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Evaluate policies for an operation
|
|
151
|
+
*
|
|
152
|
+
* @param table - Table name
|
|
153
|
+
* @param operation - Operation to test
|
|
154
|
+
* @param context - Test context
|
|
155
|
+
* @returns Evaluation result
|
|
156
|
+
*/
|
|
157
|
+
async evaluate(
|
|
158
|
+
table: string,
|
|
159
|
+
operation: Operation,
|
|
160
|
+
context: TestContext
|
|
161
|
+
): Promise<PolicyEvaluationResult> {
|
|
162
|
+
const evaluatedPolicies: PolicyEvaluationResult['evaluatedPolicies'] = []
|
|
163
|
+
|
|
164
|
+
// Check if table is registered
|
|
165
|
+
if (!this.registry.hasTable(table)) {
|
|
166
|
+
return {
|
|
167
|
+
allowed: true,
|
|
168
|
+
decisionType: 'default',
|
|
169
|
+
reason: 'Table has no RLS policies',
|
|
170
|
+
evaluatedPolicies
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check system user bypass
|
|
175
|
+
if (context.auth.isSystem) {
|
|
176
|
+
return {
|
|
177
|
+
allowed: true,
|
|
178
|
+
decisionType: 'allow',
|
|
179
|
+
reason: 'System user bypasses RLS',
|
|
180
|
+
evaluatedPolicies
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check skipFor roles
|
|
185
|
+
const skipFor = this.registry.getSkipFor(table)
|
|
186
|
+
if (skipFor.some(role => context.auth.roles.includes(role))) {
|
|
187
|
+
return {
|
|
188
|
+
allowed: true,
|
|
189
|
+
decisionType: 'allow',
|
|
190
|
+
reason: `Role bypass: ${skipFor.find(r => context.auth.roles.includes(r))}`,
|
|
191
|
+
evaluatedPolicies
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Build evaluation context
|
|
196
|
+
const evalCtx: PolicyEvaluationContext = {
|
|
197
|
+
auth: context.auth,
|
|
198
|
+
row: context.row,
|
|
199
|
+
data: context.data,
|
|
200
|
+
table,
|
|
201
|
+
operation,
|
|
202
|
+
...(context.meta !== undefined && { meta: context.meta })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Evaluate deny policies first
|
|
206
|
+
const denies = this.registry.getDenies(table, operation)
|
|
207
|
+
for (const deny of denies) {
|
|
208
|
+
const result = await this.evaluatePolicy(deny, evalCtx)
|
|
209
|
+
evaluatedPolicies.push({
|
|
210
|
+
name: deny.name,
|
|
211
|
+
type: 'deny',
|
|
212
|
+
result
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if (result) {
|
|
216
|
+
return {
|
|
217
|
+
allowed: false,
|
|
218
|
+
policyName: deny.name,
|
|
219
|
+
decisionType: 'deny',
|
|
220
|
+
reason: `Denied by policy: ${deny.name}`,
|
|
221
|
+
evaluatedPolicies
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Evaluate validate policies (for create/update)
|
|
227
|
+
if ((operation === 'create' || operation === 'update') && context.data) {
|
|
228
|
+
const validates = this.registry.getValidates(table, operation)
|
|
229
|
+
for (const validate of validates) {
|
|
230
|
+
const result = await this.evaluatePolicy(validate, evalCtx)
|
|
231
|
+
evaluatedPolicies.push({
|
|
232
|
+
name: validate.name,
|
|
233
|
+
type: 'validate',
|
|
234
|
+
result
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if (!result) {
|
|
238
|
+
return {
|
|
239
|
+
allowed: false,
|
|
240
|
+
policyName: validate.name,
|
|
241
|
+
decisionType: 'deny',
|
|
242
|
+
reason: `Validation failed: ${validate.name}`,
|
|
243
|
+
evaluatedPolicies
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Evaluate allow policies
|
|
250
|
+
const allows = this.registry.getAllows(table, operation)
|
|
251
|
+
const defaultDeny = this.registry.hasDefaultDeny(table)
|
|
252
|
+
|
|
253
|
+
if (defaultDeny && allows.length === 0) {
|
|
254
|
+
return {
|
|
255
|
+
allowed: false,
|
|
256
|
+
decisionType: 'default',
|
|
257
|
+
reason: 'No allow policies defined (default deny)',
|
|
258
|
+
evaluatedPolicies
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const allow of allows) {
|
|
263
|
+
const result = await this.evaluatePolicy(allow, evalCtx)
|
|
264
|
+
evaluatedPolicies.push({
|
|
265
|
+
name: allow.name,
|
|
266
|
+
type: 'allow',
|
|
267
|
+
result
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (result) {
|
|
271
|
+
return {
|
|
272
|
+
allowed: true,
|
|
273
|
+
policyName: allow.name,
|
|
274
|
+
decisionType: 'allow',
|
|
275
|
+
reason: `Allowed by policy: ${allow.name}`,
|
|
276
|
+
evaluatedPolicies
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// No allow policy matched
|
|
282
|
+
if (defaultDeny) {
|
|
283
|
+
return {
|
|
284
|
+
allowed: false,
|
|
285
|
+
decisionType: 'default',
|
|
286
|
+
reason: 'No allow policies matched (default deny)',
|
|
287
|
+
evaluatedPolicies
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
allowed: true,
|
|
293
|
+
decisionType: 'default',
|
|
294
|
+
reason: 'No policies matched (default allow)',
|
|
295
|
+
evaluatedPolicies
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get filter conditions for read operations
|
|
301
|
+
*
|
|
302
|
+
* @param table - Table name
|
|
303
|
+
* @param operation - Must be 'read'
|
|
304
|
+
* @param context - Test context
|
|
305
|
+
* @returns Filter conditions
|
|
306
|
+
*/
|
|
307
|
+
getFilters(
|
|
308
|
+
table: string,
|
|
309
|
+
_operation: 'read',
|
|
310
|
+
context: Pick<TestContext, 'auth' | 'meta'>
|
|
311
|
+
): FilterEvaluationResult {
|
|
312
|
+
const conditions: Record<string, unknown> = {}
|
|
313
|
+
const appliedFilters: string[] = []
|
|
314
|
+
|
|
315
|
+
// Check if table is registered
|
|
316
|
+
if (!this.registry.hasTable(table)) {
|
|
317
|
+
return { conditions, appliedFilters }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check system user bypass
|
|
321
|
+
if (context.auth.isSystem) {
|
|
322
|
+
return { conditions, appliedFilters }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Check skipFor roles
|
|
326
|
+
const skipFor = this.registry.getSkipFor(table)
|
|
327
|
+
if (skipFor.some(role => context.auth.roles.includes(role))) {
|
|
328
|
+
return { conditions, appliedFilters }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Get filters
|
|
332
|
+
const filters = this.registry.getFilters(table)
|
|
333
|
+
|
|
334
|
+
// Build evaluation context
|
|
335
|
+
const evalCtx: PolicyEvaluationContext = {
|
|
336
|
+
auth: context.auth,
|
|
337
|
+
...(context.meta !== undefined && { meta: context.meta })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Evaluate each filter
|
|
341
|
+
for (const filter of filters) {
|
|
342
|
+
const filterConditions = filter.getConditions(evalCtx)
|
|
343
|
+
Object.assign(conditions, filterConditions)
|
|
344
|
+
appliedFilters.push(filter.name)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { conditions, appliedFilters }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Test if a specific policy allows the operation
|
|
352
|
+
*
|
|
353
|
+
* @param table - Table name
|
|
354
|
+
* @param policyName - Name of the policy to test
|
|
355
|
+
* @param context - Test context
|
|
356
|
+
* @returns True if policy allows
|
|
357
|
+
*/
|
|
358
|
+
async testPolicy(
|
|
359
|
+
table: string,
|
|
360
|
+
policyName: string,
|
|
361
|
+
context: TestContext
|
|
362
|
+
): Promise<{ found: boolean; result?: boolean }> {
|
|
363
|
+
// Search in all policy types
|
|
364
|
+
const operations: Operation[] = ['read', 'create', 'update', 'delete']
|
|
365
|
+
|
|
366
|
+
for (const op of operations) {
|
|
367
|
+
// Build evaluation context once per operation
|
|
368
|
+
const evalCtx: PolicyEvaluationContext = {
|
|
369
|
+
auth: context.auth,
|
|
370
|
+
row: context.row,
|
|
371
|
+
data: context.data,
|
|
372
|
+
table,
|
|
373
|
+
operation: op,
|
|
374
|
+
...(context.meta !== undefined && { meta: context.meta })
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Check allows
|
|
378
|
+
const allows = this.registry.getAllows(table, op)
|
|
379
|
+
const allow = allows.find(p => p.name === policyName)
|
|
380
|
+
if (allow) {
|
|
381
|
+
const result = await this.evaluatePolicy(allow, evalCtx)
|
|
382
|
+
return { found: true, result }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Check denies
|
|
386
|
+
const denies = this.registry.getDenies(table, op)
|
|
387
|
+
const deny = denies.find(p => p.name === policyName)
|
|
388
|
+
if (deny) {
|
|
389
|
+
const result = await this.evaluatePolicy(deny, evalCtx)
|
|
390
|
+
return { found: true, result }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Check validates
|
|
394
|
+
const validates = this.registry.getValidates(table, op)
|
|
395
|
+
const validate = validates.find(p => p.name === policyName)
|
|
396
|
+
if (validate) {
|
|
397
|
+
const result = await this.evaluatePolicy(validate, evalCtx)
|
|
398
|
+
return { found: true, result }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { found: false }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* List all policies for a table
|
|
407
|
+
*/
|
|
408
|
+
listPolicies(table: string): {
|
|
409
|
+
allows: string[]
|
|
410
|
+
denies: string[]
|
|
411
|
+
filters: string[]
|
|
412
|
+
validates: string[]
|
|
413
|
+
} {
|
|
414
|
+
const operations: Operation[] = ['read', 'create', 'update', 'delete']
|
|
415
|
+
const allowSet = new Set<string>()
|
|
416
|
+
const denySet = new Set<string>()
|
|
417
|
+
const validateSet = new Set<string>()
|
|
418
|
+
|
|
419
|
+
for (const op of operations) {
|
|
420
|
+
this.registry.getAllows(table, op).forEach(p => allowSet.add(p.name))
|
|
421
|
+
this.registry.getDenies(table, op).forEach(p => denySet.add(p.name))
|
|
422
|
+
this.registry.getValidates(table, op).forEach(p => validateSet.add(p.name))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
allows: Array.from(allowSet),
|
|
427
|
+
denies: Array.from(denySet),
|
|
428
|
+
filters: this.registry.getFilters(table).map(f => f.name),
|
|
429
|
+
validates: Array.from(validateSet)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Get all registered tables
|
|
435
|
+
*/
|
|
436
|
+
getTables(): string[] {
|
|
437
|
+
return this.registry.getTables()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Evaluate a single policy
|
|
442
|
+
*/
|
|
443
|
+
private async evaluatePolicy(
|
|
444
|
+
policy: CompiledPolicy,
|
|
445
|
+
ctx: PolicyEvaluationContext
|
|
446
|
+
): Promise<boolean> {
|
|
447
|
+
try {
|
|
448
|
+
const result = policy.evaluate(ctx)
|
|
449
|
+
return result instanceof Promise ? await result : result
|
|
450
|
+
} catch {
|
|
451
|
+
return false // Fail closed
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// Factory Function
|
|
458
|
+
// ============================================================================
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Create a policy tester
|
|
462
|
+
*
|
|
463
|
+
* @param schema - RLS schema to test
|
|
464
|
+
* @returns PolicyTester instance
|
|
465
|
+
*/
|
|
466
|
+
export function createPolicyTester<DB = unknown>(schema: RLSSchema<DB>): PolicyTester<DB> {
|
|
467
|
+
return new PolicyTester<DB>(schema)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ============================================================================
|
|
471
|
+
// Test Helpers
|
|
472
|
+
// ============================================================================
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Create a test auth context
|
|
476
|
+
*
|
|
477
|
+
* @param overrides - Values to override
|
|
478
|
+
* @returns RLSAuthContext for testing
|
|
479
|
+
*/
|
|
480
|
+
export function createTestAuthContext(
|
|
481
|
+
overrides: Partial<RLSAuthContext> & { userId: string | number }
|
|
482
|
+
): RLSAuthContext {
|
|
483
|
+
return {
|
|
484
|
+
roles: [],
|
|
485
|
+
isSystem: false,
|
|
486
|
+
...overrides
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Create a test row
|
|
492
|
+
*
|
|
493
|
+
* @param data - Row data
|
|
494
|
+
* @returns Row object
|
|
495
|
+
*/
|
|
496
|
+
export function createTestRow<T extends Record<string, unknown>>(data: T): T {
|
|
497
|
+
return { ...data }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Assertion helpers for policy testing
|
|
502
|
+
*/
|
|
503
|
+
export const policyAssertions = {
|
|
504
|
+
/**
|
|
505
|
+
* Assert that the result is allowed
|
|
506
|
+
*/
|
|
507
|
+
assertAllowed(result: PolicyEvaluationResult, message?: string): void {
|
|
508
|
+
if (!result.allowed) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
message ?? `Expected policy to allow, but was denied: ${result.reason}`
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Assert that the result is denied
|
|
517
|
+
*/
|
|
518
|
+
assertDenied(result: PolicyEvaluationResult, message?: string): void {
|
|
519
|
+
if (result.allowed) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
message ?? `Expected policy to deny, but was allowed: ${result.reason}`
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Assert that a specific policy made the decision
|
|
528
|
+
*/
|
|
529
|
+
assertPolicyUsed(result: PolicyEvaluationResult, policyName: string, message?: string): void {
|
|
530
|
+
if (result.policyName !== policyName) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
message ?? `Expected policy "${policyName}" but was "${result.policyName}"`
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Assert that filters include expected conditions
|
|
539
|
+
*/
|
|
540
|
+
assertFiltersInclude(
|
|
541
|
+
result: FilterEvaluationResult,
|
|
542
|
+
expected: Record<string, unknown>,
|
|
543
|
+
message?: string
|
|
544
|
+
): void {
|
|
545
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
546
|
+
if (result.conditions[key] !== value) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
message ??
|
|
549
|
+
`Expected filter condition ${key}=${JSON.stringify(value)} but got ${JSON.stringify(result.conditions[key])}`
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|