@geekmidas/db 1.1.1 → 10.0.0-alpha.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 (66) hide show
  1. package/dist/errors-CfFCan-X.mjs +69 -0
  2. package/dist/errors-CfFCan-X.mjs.map +1 -0
  3. package/dist/errors-Cs5jlAv-.cjs +93 -0
  4. package/dist/errors-Cs5jlAv-.cjs.map +1 -0
  5. package/dist/errors.cjs +6 -0
  6. package/dist/errors.d.cts +53 -0
  7. package/dist/errors.d.cts.map +1 -0
  8. package/dist/errors.d.mts +53 -0
  9. package/dist/errors.d.mts.map +1 -0
  10. package/dist/errors.mjs +3 -0
  11. package/dist/kysely/pagination.cjs +1 -1
  12. package/dist/kysely/pagination.d.mts +1 -1
  13. package/dist/kysely/pagination.mjs +1 -1
  14. package/dist/{kysely-Dm1w5gAY.d.mts → kysely-BAedYsvn.d.mts} +1 -1
  15. package/dist/{kysely-Dm1w5gAY.d.mts.map → kysely-BAedYsvn.d.mts.map} +1 -1
  16. package/dist/{kysely-DmfA94RY.mjs → kysely-Bo_Zg-D3.mjs} +1 -1
  17. package/dist/{kysely-DmfA94RY.mjs.map → kysely-Bo_Zg-D3.mjs.map} +1 -1
  18. package/dist/{kysely-8WPSKCZG.cjs → kysely-LWvS2ekY.cjs} +1 -1
  19. package/dist/{kysely-8WPSKCZG.cjs.map → kysely-LWvS2ekY.cjs.map} +1 -1
  20. package/dist/kysely.cjs +1 -1
  21. package/dist/kysely.d.mts +1 -1
  22. package/dist/kysely.mjs +1 -1
  23. package/dist/objection/pagination.cjs +1 -1
  24. package/dist/objection/pagination.d.mts +1 -1
  25. package/dist/objection/pagination.mjs +1 -1
  26. package/dist/{pagination-Bdoa4PVj.cjs → pagination-Bu1dqOy0.cjs} +1 -1
  27. package/dist/{pagination-Bdoa4PVj.cjs.map → pagination-Bu1dqOy0.cjs.map} +1 -1
  28. package/dist/{pagination-BDLa7Yb_.mjs → pagination-CQ8QHXaL.mjs} +1 -1
  29. package/dist/{pagination-BDLa7Yb_.mjs.map → pagination-CQ8QHXaL.mjs.map} +1 -1
  30. package/dist/{pagination-BziGl-B8.d.mts → pagination-kv-KWS-7.d.mts} +1 -1
  31. package/dist/{pagination-BziGl-B8.d.mts.map → pagination-kv-KWS-7.d.mts.map} +1 -1
  32. package/dist/pagination.cjs +1 -1
  33. package/dist/pagination.d.mts +1 -1
  34. package/dist/pagination.mjs +1 -1
  35. package/dist/pg/roles.cjs +154 -0
  36. package/dist/pg/roles.cjs.map +1 -0
  37. package/dist/pg/roles.d.cts +82 -0
  38. package/dist/pg/roles.d.cts.map +1 -0
  39. package/dist/pg/roles.d.mts +82 -0
  40. package/dist/pg/roles.d.mts.map +1 -0
  41. package/dist/pg/roles.mjs +150 -0
  42. package/dist/pg/roles.mjs.map +1 -0
  43. package/dist/pg/url.cjs +33 -0
  44. package/dist/pg/url.cjs.map +1 -0
  45. package/dist/pg/url.d.cts +47 -0
  46. package/dist/pg/url.d.cts.map +1 -0
  47. package/dist/pg/url.d.mts +47 -0
  48. package/dist/pg/url.d.mts.map +1 -0
  49. package/dist/pg/url.mjs +33 -0
  50. package/dist/pg/url.mjs.map +1 -0
  51. package/dist/rls.cjs +1 -1
  52. package/dist/rls.d.mts +1 -1
  53. package/dist/rls.mjs +1 -1
  54. package/package.json +34 -1
  55. package/CHANGELOG.md +0 -47
  56. package/src/__tests__/kysely.integration.spec.ts +0 -741
  57. package/src/__tests__/kysely.spec.ts +0 -394
  58. package/src/__tests__/rls.spec.ts +0 -716
  59. package/src/kysely/__tests__/pagination.integration.spec.ts +0 -341
  60. package/src/kysely/pagination.ts +0 -113
  61. package/src/kysely.ts +0 -33
  62. package/src/objection/__tests__/pagination.integration.spec.ts +0 -291
  63. package/src/objection/pagination.ts +0 -98
  64. package/src/pagination.ts +0 -49
  65. package/src/rls.ts +0 -90
  66. package/tsconfig.json +0 -9
@@ -1,716 +0,0 @@
1
- import {
2
- CamelCasePlugin,
3
- type Generated,
4
- Kysely,
5
- PostgresDialect,
6
- sql,
7
- } from 'kysely';
8
- import pg from 'pg';
9
- import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
10
- import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
11
- import { RLS_BYPASS, type RlsContext, withRlsContext } from '../rls';
12
-
13
- interface TestDatabase {
14
- rlsTestOrders: {
15
- id: Generated<number>;
16
- tenantId: string;
17
- userId: string;
18
- amount: number;
19
- createdAt: Generated<Date>;
20
- };
21
- }
22
-
23
- interface RlsPolicyDatabase {
24
- rlsPolicyItems: {
25
- id: Generated<number>;
26
- tenantId: string;
27
- userId: string;
28
- amount: number;
29
- createdAt: Generated<Date>;
30
- };
31
- }
32
-
33
- describe('RLS Utility - Integration Tests', () => {
34
- let db: Kysely<TestDatabase>;
35
-
36
- beforeAll(async () => {
37
- db = new Kysely<TestDatabase>({
38
- dialect: new PostgresDialect({
39
- pool: new pg.Pool({
40
- ...TEST_DATABASE_CONFIG,
41
- database: 'postgres',
42
- }),
43
- }),
44
- plugins: [new CamelCasePlugin()],
45
- });
46
-
47
- // Create test table
48
- await db.schema
49
- .createTable('rls_test_orders')
50
- .ifNotExists()
51
- .addColumn('id', 'serial', (col) => col.primaryKey())
52
- .addColumn('tenant_id', 'varchar', (col) => col.notNull())
53
- .addColumn('user_id', 'varchar', (col) => col.notNull())
54
- .addColumn('amount', 'numeric(10, 2)', (col) => col.notNull())
55
- .addColumn('created_at', 'timestamp', (col) =>
56
- col.defaultTo(sql`now()`).notNull(),
57
- )
58
- .execute();
59
- });
60
-
61
- afterEach(async () => {
62
- // Clean up data after each test
63
- await db.deleteFrom('rlsTestOrders').execute();
64
- });
65
-
66
- afterAll(async () => {
67
- // Drop table and close connection
68
- await db.schema.dropTable('rls_test_orders').ifExists().execute();
69
- await db.destroy();
70
- });
71
-
72
- describe('withRlsContext', () => {
73
- it('should execute callback within transaction', async () => {
74
- const result = await withRlsContext(
75
- db,
76
- { user_id: 'user-123' },
77
- async (trx) => {
78
- // Insert an order within the RLS context
79
- const order = await trx
80
- .insertInto('rlsTestOrders')
81
- .values({
82
- tenantId: 'tenant-1',
83
- userId: 'user-123',
84
- amount: 100,
85
- })
86
- .returningAll()
87
- .executeTakeFirstOrThrow();
88
-
89
- return order;
90
- },
91
- );
92
-
93
- expect(result.id).toBeDefined();
94
- expect(result.userId).toBe('user-123');
95
- });
96
-
97
- it('should set RLS context variables with set_config', async () => {
98
- const capturedValues = await withRlsContext(
99
- db,
100
- { user_id: 'user-123', tenant_id: 'tenant-456' },
101
- async (trx) => {
102
- // Read the session variables using current_setting
103
- const userId = await sql<{ value: string }>`
104
- SELECT current_setting('app.user_id', true) as value
105
- `
106
- .execute(trx)
107
- .then((r) => r.rows[0]?.value);
108
-
109
- const tenantId = await sql<{ value: string }>`
110
- SELECT current_setting('app.tenant_id', true) as value
111
- `
112
- .execute(trx)
113
- .then((r) => r.rows[0]?.value);
114
-
115
- return { userId, tenantId };
116
- },
117
- );
118
-
119
- expect(capturedValues.userId).toBe('user-123');
120
- expect(capturedValues.tenantId).toBe('tenant-456');
121
- });
122
-
123
- it('should use custom prefix when specified', async () => {
124
- const capturedValue = await withRlsContext(
125
- db,
126
- { user_id: 'user-123' },
127
- async (trx) => {
128
- const value = await sql<{ value: string }>`
129
- SELECT current_setting('rls.user_id', true) as value
130
- `
131
- .execute(trx)
132
- .then((r) => r.rows[0]?.value);
133
-
134
- return value;
135
- },
136
- { prefix: 'rls' },
137
- );
138
-
139
- expect(capturedValue).toBe('user-123');
140
- });
141
-
142
- it('should skip null and undefined values', async () => {
143
- const capturedValues = await withRlsContext(
144
- db,
145
- {
146
- user_id: 'user-123',
147
- nullable_field: null,
148
- undefined_field: undefined,
149
- },
150
- async (trx) => {
151
- const userId = await sql<{ value: string }>`
152
- SELECT current_setting('app.user_id', true) as value
153
- `
154
- .execute(trx)
155
- .then((r) => r.rows[0]?.value);
156
-
157
- // These should return null since they weren't set
158
- const nullableField = await sql<{ value: string | null }>`
159
- SELECT current_setting('app.nullable_field', true) as value
160
- `
161
- .execute(trx)
162
- .then((r) => r.rows[0]?.value);
163
-
164
- const undefinedField = await sql<{ value: string | null }>`
165
- SELECT current_setting('app.undefined_field', true) as value
166
- `
167
- .execute(trx)
168
- .then((r) => r.rows[0]?.value);
169
-
170
- return { userId, nullableField, undefinedField };
171
- },
172
- );
173
-
174
- expect(capturedValues.userId).toBe('user-123');
175
- expect(capturedValues.nullableField).toBeNull();
176
- expect(capturedValues.undefinedField).toBeNull();
177
- });
178
-
179
- it('should convert number values to strings', async () => {
180
- const capturedValue = await withRlsContext(
181
- db,
182
- { count: 42 },
183
- async (trx) => {
184
- return await sql<{ value: string }>`
185
- SELECT current_setting('app.count', true) as value
186
- `
187
- .execute(trx)
188
- .then((r) => r.rows[0]?.value);
189
- },
190
- );
191
-
192
- expect(capturedValue).toBe('42');
193
- });
194
-
195
- it('should convert boolean values to strings', async () => {
196
- const capturedValues = await withRlsContext(
197
- db,
198
- { is_admin: true, is_guest: false },
199
- async (trx) => {
200
- const isAdmin = await sql<{ value: string }>`
201
- SELECT current_setting('app.is_admin', true) as value
202
- `
203
- .execute(trx)
204
- .then((r) => r.rows[0]?.value);
205
-
206
- const isGuest = await sql<{ value: string }>`
207
- SELECT current_setting('app.is_guest', true) as value
208
- `
209
- .execute(trx)
210
- .then((r) => r.rows[0]?.value);
211
-
212
- return { isAdmin, isGuest };
213
- },
214
- );
215
-
216
- expect(capturedValues.isAdmin).toBe('true');
217
- expect(capturedValues.isGuest).toBe('false');
218
- });
219
-
220
- it('should propagate callback return value', async () => {
221
- const expectedResult = { id: 123, name: 'Test User' };
222
-
223
- const result = await withRlsContext(
224
- db,
225
- { user_id: 'user-123' },
226
- async () => expectedResult,
227
- );
228
-
229
- expect(result).toEqual(expectedResult);
230
- });
231
-
232
- it('should propagate errors from callback', async () => {
233
- const error = new Error('Query failed');
234
-
235
- await expect(
236
- withRlsContext(db, { user_id: 'user-123' }, async () => {
237
- throw error;
238
- }),
239
- ).rejects.toThrow('Query failed');
240
- });
241
-
242
- it('should handle empty context', async () => {
243
- const result = await withRlsContext(db, {}, async (trx) => {
244
- const order = await trx
245
- .insertInto('rlsTestOrders')
246
- .values({
247
- tenantId: 'tenant-1',
248
- userId: 'user-empty',
249
- amount: 50,
250
- })
251
- .returningAll()
252
- .executeTakeFirstOrThrow();
253
-
254
- return order;
255
- });
256
-
257
- expect(result.userId).toBe('user-empty');
258
- });
259
-
260
- it('should scope variables to transaction (not visible outside)', async () => {
261
- // Set a variable inside a transaction
262
- await withRlsContext(
263
- db,
264
- { scoped_var: 'inside-transaction' },
265
- async (trx) => {
266
- // Verify it's set inside
267
- const inside = await sql<{ value: string }>`
268
- SELECT current_setting('app.scoped_var', true) as value
269
- `
270
- .execute(trx)
271
- .then((r) => r.rows[0]?.value);
272
-
273
- expect(inside).toBe('inside-transaction');
274
- return 'done';
275
- },
276
- );
277
-
278
- // Verify it's not the transaction value outside
279
- // PostgreSQL returns empty string for unset custom variables (not null)
280
- const outside = await sql<{ value: string | null }>`
281
- SELECT current_setting('app.scoped_var', true) as value
282
- `
283
- .execute(db)
284
- .then((r) => r.rows[0]?.value);
285
-
286
- // The value should NOT be 'inside-transaction' - it's cleared/reset
287
- expect(outside).not.toBe('inside-transaction');
288
- // PostgreSQL returns empty string for missing custom settings
289
- expect(outside === '' || outside === null).toBe(true);
290
- });
291
-
292
- it('should reuse existing transaction', async () => {
293
- const result = await withRlsContext(
294
- db,
295
- { outer_var: 'outer' },
296
- async (outerTrx) => {
297
- // Nested withRlsContext should reuse the same transaction
298
- const innerResult = await withRlsContext(
299
- outerTrx,
300
- { inner_var: 'inner' },
301
- async (innerTrx) => {
302
- // Both variables should be visible in nested context
303
- const outerVar = await sql<{ value: string }>`
304
- SELECT current_setting('app.outer_var', true) as value
305
- `
306
- .execute(innerTrx)
307
- .then((r) => r.rows[0]?.value);
308
-
309
- const innerVar = await sql<{ value: string }>`
310
- SELECT current_setting('app.inner_var', true) as value
311
- `
312
- .execute(innerTrx)
313
- .then((r) => r.rows[0]?.value);
314
-
315
- return { outerVar, innerVar };
316
- },
317
- );
318
-
319
- return innerResult;
320
- },
321
- );
322
-
323
- expect(result.outerVar).toBe('outer');
324
- expect(result.innerVar).toBe('inner');
325
- });
326
-
327
- it('should rollback on error including RLS context', async () => {
328
- try {
329
- await withRlsContext(
330
- db,
331
- { rollback_test: 'should-rollback' },
332
- async (trx) => {
333
- await trx
334
- .insertInto('rlsTestOrders')
335
- .values({
336
- tenantId: 'tenant-rollback',
337
- userId: 'user-rollback',
338
- amount: 999,
339
- })
340
- .execute();
341
-
342
- throw new Error('Force rollback');
343
- },
344
- );
345
- } catch {
346
- // Expected error
347
- }
348
-
349
- // Verify the insert was rolled back
350
- const orders = await db
351
- .selectFrom('rlsTestOrders')
352
- .selectAll()
353
- .where('tenantId', '=', 'tenant-rollback')
354
- .execute();
355
-
356
- expect(orders).toHaveLength(0);
357
- });
358
-
359
- it('should pass transaction settings (isolation level)', async () => {
360
- const result = await withRlsContext(
361
- db,
362
- { user_id: 'user-serializable' },
363
- async (trx) => {
364
- // This query should work under any isolation level
365
- const order = await trx
366
- .insertInto('rlsTestOrders')
367
- .values({
368
- tenantId: 'tenant-serializable',
369
- userId: 'user-serializable',
370
- amount: 200,
371
- })
372
- .returningAll()
373
- .executeTakeFirstOrThrow();
374
-
375
- return order;
376
- },
377
- { settings: { isolationLevel: 'serializable' } },
378
- );
379
-
380
- expect(result.userId).toBe('user-serializable');
381
- });
382
-
383
- it('should support different return types', async () => {
384
- // Number
385
- const numResult = await withRlsContext(db, {}, async () => 42);
386
- expect(numResult).toBe(42);
387
-
388
- // String
389
- const strResult = await withRlsContext(db, {}, async () => 'test');
390
- expect(strResult).toBe('test');
391
-
392
- // Boolean
393
- const boolResult = await withRlsContext(db, {}, async () => true);
394
- expect(boolResult).toBe(true);
395
-
396
- // Array
397
- const arrResult = await withRlsContext(db, {}, async () => [1, 2, 3]);
398
- expect(arrResult).toEqual([1, 2, 3]);
399
-
400
- // Object
401
- const objResult = await withRlsContext(db, {}, async () => ({
402
- key: 'value',
403
- }));
404
- expect(objResult).toEqual({ key: 'value' });
405
- });
406
- });
407
-
408
- describe('RLS_BYPASS', () => {
409
- it('should be a unique symbol', () => {
410
- expect(typeof RLS_BYPASS).toBe('symbol');
411
- expect(RLS_BYPASS.description).toBe('geekmidas.rls.bypass');
412
- });
413
-
414
- it('should be the same symbol across imports', () => {
415
- const symbolFromGlobal = Symbol.for('geekmidas.rls.bypass');
416
- expect(RLS_BYPASS).toBe(symbolFromGlobal);
417
- });
418
- });
419
-
420
- describe('RlsContext type', () => {
421
- it('should accept string values', () => {
422
- const context: RlsContext = {
423
- user_id: 'user-123',
424
- tenant_id: 'tenant-456',
425
- };
426
- expect(context.user_id).toBe('user-123');
427
- });
428
-
429
- it('should accept number values', () => {
430
- const context: RlsContext = {
431
- count: 42,
432
- decimal: 3.14,
433
- };
434
- expect(context.count).toBe(42);
435
- });
436
-
437
- it('should accept boolean values', () => {
438
- const context: RlsContext = {
439
- is_admin: true,
440
- is_guest: false,
441
- };
442
- expect(context.is_admin).toBe(true);
443
- });
444
-
445
- it('should accept null and undefined values', () => {
446
- const context: RlsContext = {
447
- nullable: null,
448
- optional: undefined,
449
- };
450
- expect(context.nullable).toBe(null);
451
- expect(context.optional).toBe(undefined);
452
- });
453
- });
454
-
455
- describe('RLS Policy Enforcement', () => {
456
- let adminDb: Kysely<RlsPolicyDatabase>;
457
- let userDb: Kysely<RlsPolicyDatabase>;
458
-
459
- beforeAll(async () => {
460
- // Admin connection (superuser bypasses RLS)
461
- adminDb = new Kysely<RlsPolicyDatabase>({
462
- dialect: new PostgresDialect({
463
- pool: new pg.Pool({
464
- ...TEST_DATABASE_CONFIG,
465
- database: 'postgres',
466
- }),
467
- }),
468
- plugins: [new CamelCasePlugin()],
469
- });
470
-
471
- // Clean up from any previous failed runs
472
- await sql`DROP TABLE IF EXISTS rls_policy_items CASCADE`.execute(adminDb);
473
-
474
- // Create non-superuser role (RLS doesn't apply to superusers)
475
- await sql`
476
- DO $$ BEGIN
477
- IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'rls_test_role') THEN
478
- CREATE ROLE rls_test_role LOGIN PASSWORD 'rls_test_pass';
479
- END IF;
480
- END $$
481
- `.execute(adminDb);
482
-
483
- // Create table
484
- await adminDb.schema
485
- .createTable('rls_policy_items')
486
- .addColumn('id', 'serial', (col) => col.primaryKey())
487
- .addColumn('tenant_id', 'varchar', (col) => col.notNull())
488
- .addColumn('user_id', 'varchar', (col) => col.notNull())
489
- .addColumn('amount', 'numeric(10, 2)', (col) => col.notNull())
490
- .addColumn('created_at', 'timestamp', (col) =>
491
- col.defaultTo(sql`now()`).notNull(),
492
- )
493
- .execute();
494
-
495
- // Enable RLS
496
- await sql`ALTER TABLE rls_policy_items ENABLE ROW LEVEL SECURITY`.execute(
497
- adminDb,
498
- );
499
-
500
- // Create tenant isolation policies
501
- await sql`
502
- CREATE POLICY tenant_select ON rls_policy_items
503
- FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))
504
- `.execute(adminDb);
505
-
506
- await sql`
507
- CREATE POLICY tenant_insert ON rls_policy_items
508
- FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id', true))
509
- `.execute(adminDb);
510
-
511
- await sql`
512
- CREATE POLICY tenant_update ON rls_policy_items
513
- FOR UPDATE
514
- USING (tenant_id = current_setting('app.tenant_id', true))
515
- WITH CHECK (tenant_id = current_setting('app.tenant_id', true))
516
- `.execute(adminDb);
517
-
518
- await sql`
519
- CREATE POLICY tenant_delete ON rls_policy_items
520
- FOR DELETE USING (tenant_id = current_setting('app.tenant_id', true))
521
- `.execute(adminDb);
522
-
523
- // Grant permissions to test role
524
- await sql`GRANT ALL ON rls_policy_items TO rls_test_role`.execute(
525
- adminDb,
526
- );
527
- await sql`GRANT USAGE, SELECT ON SEQUENCE rls_policy_items_id_seq TO rls_test_role`.execute(
528
- adminDb,
529
- );
530
-
531
- // Seed data as admin (superuser bypasses RLS)
532
- await adminDb
533
- .insertInto('rlsPolicyItems')
534
- .values([
535
- { tenantId: 'tenant-a', userId: 'user-1', amount: 100 },
536
- { tenantId: 'tenant-a', userId: 'user-2', amount: 200 },
537
- { tenantId: 'tenant-b', userId: 'user-3', amount: 300 },
538
- { tenantId: 'tenant-b', userId: 'user-4', amount: 400 },
539
- { tenantId: 'tenant-c', userId: 'user-5', amount: 500 },
540
- ])
541
- .execute();
542
-
543
- // Create connection as non-superuser (subject to RLS policies)
544
- userDb = new Kysely<RlsPolicyDatabase>({
545
- dialect: new PostgresDialect({
546
- pool: new pg.Pool({
547
- host: TEST_DATABASE_CONFIG.host,
548
- port: TEST_DATABASE_CONFIG.port,
549
- user: 'rls_test_role',
550
- password: 'rls_test_pass',
551
- database: 'postgres',
552
- }),
553
- }),
554
- plugins: [new CamelCasePlugin()],
555
- });
556
- });
557
-
558
- afterAll(async () => {
559
- await userDb.destroy();
560
- await sql`DROP TABLE IF EXISTS rls_policy_items CASCADE`.execute(adminDb);
561
- await sql`DROP ROLE IF EXISTS rls_test_role`.execute(adminDb);
562
- await adminDb.destroy();
563
- });
564
-
565
- it('should only return rows matching the tenant context', async () => {
566
- const rows = await withRlsContext(
567
- userDb,
568
- { tenant_id: 'tenant-a' },
569
- async (trx) => {
570
- return trx.selectFrom('rlsPolicyItems').selectAll().execute();
571
- },
572
- );
573
-
574
- expect(rows).toHaveLength(2);
575
- expect(rows.every((r) => r.tenantId === 'tenant-a')).toBe(true);
576
- });
577
-
578
- it('should completely isolate tenants from each other', async () => {
579
- const rows = await withRlsContext(
580
- userDb,
581
- { tenant_id: 'tenant-b' },
582
- async (trx) => {
583
- return trx.selectFrom('rlsPolicyItems').selectAll().execute();
584
- },
585
- );
586
-
587
- expect(rows).toHaveLength(2);
588
- expect(rows.every((r) => r.tenantId === 'tenant-b')).toBe(true);
589
- const userIds = rows.map((r) => r.userId);
590
- expect(userIds).not.toContain('user-1');
591
- expect(userIds).not.toContain('user-2');
592
- });
593
-
594
- it('should return no rows when tenant context is not set', async () => {
595
- const rows = await withRlsContext(userDb, {}, async (trx) => {
596
- return trx.selectFrom('rlsPolicyItems').selectAll().execute();
597
- });
598
-
599
- expect(rows).toHaveLength(0);
600
- });
601
-
602
- it('should allow inserting rows that match the tenant context', async () => {
603
- const row = await withRlsContext(
604
- userDb,
605
- { tenant_id: 'tenant-a' },
606
- async (trx) => {
607
- return trx
608
- .insertInto('rlsPolicyItems')
609
- .values({
610
- tenantId: 'tenant-a',
611
- userId: 'user-insert-test',
612
- amount: 999,
613
- })
614
- .returningAll()
615
- .executeTakeFirstOrThrow();
616
- },
617
- );
618
-
619
- expect(row.tenantId).toBe('tenant-a');
620
- expect(row.userId).toBe('user-insert-test');
621
-
622
- // Clean up
623
- await sql`DELETE FROM rls_policy_items WHERE user_id = 'user-insert-test'`.execute(
624
- adminDb,
625
- );
626
- });
627
-
628
- it('should reject inserting rows that violate the tenant policy', async () => {
629
- await expect(
630
- withRlsContext(userDb, { tenant_id: 'tenant-a' }, async (trx) => {
631
- return trx
632
- .insertInto('rlsPolicyItems')
633
- .values({
634
- tenantId: 'tenant-b',
635
- userId: 'user-cross-tenant',
636
- amount: 666,
637
- })
638
- .execute();
639
- }),
640
- ).rejects.toThrow(/row-level security/i);
641
- });
642
-
643
- it('should not update rows belonging to another tenant', async () => {
644
- const result = await withRlsContext(
645
- userDb,
646
- { tenant_id: 'tenant-a' },
647
- async (trx) => {
648
- return trx
649
- .updateTable('rlsPolicyItems')
650
- .set({ amount: 111 })
651
- .where('userId', '=', 'user-3') // belongs to tenant-b
652
- .executeTakeFirst();
653
- },
654
- );
655
-
656
- expect(Number(result.numUpdatedRows)).toBe(0);
657
-
658
- // Verify row is unchanged
659
- const unchanged = await sql<{ amount: string }>`
660
- SELECT amount FROM rls_policy_items WHERE user_id = 'user-3'
661
- `
662
- .execute(adminDb)
663
- .then((r) => r.rows[0]);
664
-
665
- expect(Number(unchanged?.amount)).toBe(300);
666
- });
667
-
668
- it('should reject updates that reassign a row to another tenant', async () => {
669
- await expect(
670
- withRlsContext(userDb, { tenant_id: 'tenant-a' }, async (trx) => {
671
- return trx
672
- .updateTable('rlsPolicyItems')
673
- .set({ tenantId: 'tenant-b' })
674
- .where('userId', '=', 'user-1')
675
- .execute();
676
- }),
677
- ).rejects.toThrow(/row-level security/i);
678
- });
679
-
680
- it('should not delete rows belonging to another tenant', async () => {
681
- const result = await withRlsContext(
682
- userDb,
683
- { tenant_id: 'tenant-a' },
684
- async (trx) => {
685
- return trx
686
- .deleteFrom('rlsPolicyItems')
687
- .where('userId', '=', 'user-3') // belongs to tenant-b
688
- .executeTakeFirst();
689
- },
690
- );
691
-
692
- expect(Number(result.numDeletedRows)).toBe(0);
693
- });
694
-
695
- it('should allow deleting rows belonging to the current tenant', async () => {
696
- // Insert a row to delete
697
- await sql`
698
- INSERT INTO rls_policy_items (tenant_id, user_id, amount)
699
- VALUES ('tenant-a', 'user-to-delete', 999)
700
- `.execute(adminDb);
701
-
702
- const result = await withRlsContext(
703
- userDb,
704
- { tenant_id: 'tenant-a' },
705
- async (trx) => {
706
- return trx
707
- .deleteFrom('rlsPolicyItems')
708
- .where('userId', '=', 'user-to-delete')
709
- .executeTakeFirst();
710
- },
711
- );
712
-
713
- expect(Number(result.numDeletedRows)).toBe(1);
714
- });
715
- });
716
- });