@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.
@@ -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
+ }