UNPKG

@accounter/server

Version:
406 lines (325 loc) 16.1 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Pool, PoolClient } from 'pg'; import type { AuthContext } from '../../../shared/types/auth.js'; import { DBProvider } from '../db.provider.js'; import { TenantAwareDBClient } from '../tenant-db-client.js'; import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js'; describe('TenantAwareDBClient', () => { let mockPoolClient: PoolClient; let mockPool: Pool; let mockDBProvider: DBProvider; let mockAuthContext: AuthContext; let tenantDBClient: TenantAwareDBClient; beforeEach(() => { // Mock PoolClient mockPoolClient = { query: vi.fn(), release: vi.fn(), } as unknown as PoolClient; // Mock Pool mockPool = { connect: vi.fn().mockResolvedValue(mockPoolClient), } as unknown as Pool; // Mock DBProvider mockDBProvider = { pool: mockPool, healthCheck: vi.fn(), shutdown: vi.fn(), query: vi.fn(), } as unknown as DBProvider; // Mock AuthContext mockAuthContext = { authType: 'jwt', token: 'token', user: { userId: 'user-123', roleId: 'admin', email: 'test@test.com', permissions: [], emailVerified: true, permissionsVersion: 1, }, tenant: { businessId: 'business-456', roleId: 'admin', }, activeReadScope: { businessIds: ['business-456'] }, }; const authContextProvider = {getAuthContext: () => Promise.resolve(mockAuthContext)} as AuthContextProvider; tenantDBClient = new TenantAwareDBClient(mockDBProvider, authContextProvider); }); describe('query', () => { it('should throw if auth context is missing', async () => { tenantDBClient = new TenantAwareDBClient(mockDBProvider, null as any); await expect(tenantDBClient.query('SELECT 1')) .rejects .toThrow('Auth context not available. TenantAwareDBClient requires active authentication.'); }); it('should start a transaction if none exists', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); await tenantDBClient.query('SELECT 1'); // Check transaction flow expect(mockPool.connect).toHaveBeenCalled(); expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN'); expect(mockPoolClient.query).toHaveBeenCalledWith(expect.stringContaining("set_config('app.current_business_id', $1, true)"), expect.anything()); expect(mockPoolClient.query).toHaveBeenCalledWith('SELECT 1', undefined); expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT'); expect(mockPoolClient.release).toHaveBeenCalled(); }); it('should reuse existing transaction', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); await tenantDBClient.transaction(async () => { await tenantDBClient.query('SELECT 1'); }); // connect called only once expect(mockPool.connect).toHaveBeenCalledTimes(1); // BEGIN called once expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN'); // SELECT called inside transaction expect(mockPoolClient.query).toHaveBeenCalledWith('SELECT 1', undefined); }); }); describe('transaction', () => { it('should throw if auth context is missing', async () => { tenantDBClient = new TenantAwareDBClient(mockDBProvider, null as any); await expect(tenantDBClient.transaction(async () => {})) .rejects .toThrow('Auth context not available. TenantAwareDBClient requires active authentication.'); }); it('should handle nested transactions with savepoints', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); await tenantDBClient.transaction(async () => { await tenantDBClient.transaction(async () => { await tenantDBClient.query('SELECT 1'); }); }); expect(mockPoolClient.query).toHaveBeenCalledWith('SAVEPOINT sp_2'); expect(mockPoolClient.query).toHaveBeenCalledWith('RELEASE SAVEPOINT sp_2'); }); it('should fallback nested transaction on error', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); const error = new Error('nested error'); await expect(tenantDBClient.transaction(async () => { await tenantDBClient.transaction(async () => { throw error; }); })).rejects.toThrow(error); expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK TO SAVEPOINT sp_2'); }); it('should rollback top-level transaction on error', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); const error = new Error('top error'); await expect(tenantDBClient.transaction(async () => { throw error; })).rejects.toThrow(error); expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK'); expect(mockPoolClient.release).toHaveBeenCalled(); }); }); describe('dispose', () => { it('should rollback and release active client', async () => { // Start a transaction but don't finish it (simulate interruption by catching reference?) // Hard to simulate "mid-flight" without modifying state directly or using a hanging promise? // We can just call transaction and rely on internal state if we mock connect/query? // Alternatively, we can inspect private state or simulate a hanging transaction? // Easier: Manually set activeClient via `any` cast for testing dispose logic if needed, // or start a transaction that awaits a signal? // Let's manually set state for testing dispose specifically (tenantDBClient as any).activeClient = mockPoolClient; await tenantDBClient.dispose(); expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK'); expect(mockPoolClient.release).toHaveBeenCalled(); expect((tenantDBClient as any).activeClient).toBeNull(); }); it('should wait for ongoing transaction to complete before disposing', async () => { // Start a transaction first to initialize activeClient vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); let transactionFinished = false; void tenantDBClient.transaction(async () => { await new Promise(resolve => setTimeout(resolve, 50)); transactionFinished = true; }); // Ensure transaction started (mutex acquired) await new Promise(resolve => setTimeout(resolve, 10)); // Call dispose. This should await the mutex (wait for transaction) await tenantDBClient.dispose(); expect(transactionFinished).toBe(true); expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT'); // completed naturally expect(mockPoolClient.release).toHaveBeenCalled(); // Verify subsequent calls fail await expect(tenantDBClient.query('SELECT 1')).rejects.toThrow('TenantAwareDBClient is already disposed'); }); it('should be idempotent', async () => { (tenantDBClient as any).activeClient = mockPoolClient; await tenantDBClient.dispose(); await tenantDBClient.dispose(); expect(mockPoolClient.release).toHaveBeenCalledTimes(1); }); it('should release client even if rollback fails', async () => { const rollbackError = new Error('Rollback failed'); vi.mocked(mockPoolClient.query).mockRejectedValueOnce(rollbackError); (tenantDBClient as any).activeClient = mockPoolClient; await tenantDBClient.dispose(); expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK'); expect(mockPoolClient.release).toHaveBeenCalled(); expect((tenantDBClient as any).activeClient).toBeNull(); }); it('should return early on timeout if mutex is held (avoid race condition)', async () => { // Use fake timers to fast-forward the 5s timeout vi.useFakeTimers(); // Mock active client (tenantDBClient as any).activeClient = mockPoolClient; // Acquire mutex manually to simulate stuck transaction // We need access to the mutex which is private. // Casting to any allows access for testing. const release = await (tenantDBClient as any).mutex.acquire(); const disposePromise = tenantDBClient.dispose(); // Advance timers by 5000ms + buffer (must handle async promise resolution) await vi.advanceTimersByTimeAsync(6000); await disposePromise; // Cleanup release(); vi.useRealTimers(); // Assertions // 1. Client should NOT be released because we return early to avoid destroying // an actively-used connection (prevents race condition) expect(mockPoolClient.release).not.toHaveBeenCalled(); // 2. activeClient should still be set (cleanup deferred to transaction's finally block) expect((tenantDBClient as any).activeClient).toBe(mockPoolClient); // 3. isDisposed SHOULD be true to prevent further usage of this instance expect((tenantDBClient as any).isDisposed).toBe(true); }); }); describe('RLS variables', () => { it('should set correct RLS variables', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); await tenantDBClient.query('SELECT 1'); const expectedVars = [ 'business-456', 'user-123', 'jwt', '{"business-456"}', ]; // Find the call that sets variables const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_id', $1, true)") ); expect(setCall).toBeDefined(); expect(setCall![1]).toEqual(expectedVars); }); it('should throw if businessId is missing in auth context and no single-scoped business', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); // No tenant.businessId and no activeReadScope → no write target can be derived (tenantDBClient as any).authContext = { ...mockAuthContext, tenant: {}, activeReadScope: undefined }; (tenantDBClient as any).authContextInitialized = true; await expect(tenantDBClient.query('SELECT 1')).rejects.toThrow('Missing businessId in AuthContext'); }); it('should use the single scoped business as write target when tenant.businessId is absent', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); // tenant.businessId missing but activeReadScope has exactly one business (tenantDBClient as any).authContext = { ...mockAuthContext, tenant: {}, activeReadScope: { businessIds: ['business-456'] }, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_id', $1, true)"), ); expect(setCall).toBeDefined(); expect(setCall![1][0]).toBe('business-456'); }); it('sets the write target and the read-scope array session variables', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes('set_config('), ); expect(setCall).toBeDefined(); const sql = setCall![0] as string; expect(sql).toContain("set_config('app.current_business_id', $1, true)"); expect(sql).toContain("set_config('app.current_user_id', $2, true)"); expect(sql).toContain("set_config('app.auth_type', $3, true)"); expect(sql).toContain("set_config('app.current_business_scope', $4, true)"); expect(setCall![1]).toHaveLength(4); }); it('serializes a multi-business read scope as a Postgres array literal', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); (tenantDBClient as any).authContext = { ...mockAuthContext, activeReadScope: { businessIds: ['business-456', 'business-789'] }, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_scope', $4, true)"), ); expect(setCall).toBeDefined(); expect(setCall![1][3]).toBe('{"business-456","business-789"}'); }); it('passes an empty read scope when none is resolved (DB falls back to single business)', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); (tenantDBClient as any).authContext = { ...mockAuthContext, activeReadScope: undefined, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_scope', $4, true)"), ); expect(setCall).toBeDefined(); expect(setCall![1][3]).toBe(''); }); it('uses single scoped business as write target, overriding primary tenant', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); // Primary is 'business-456', but X-Business-Scope narrows to 'business-789' (tenantDBClient as any).authContext = { ...mockAuthContext, tenant: { businessId: 'business-456', roleId: 'admin' }, activeReadScope: { businessIds: ['business-789'] }, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_id', $1, true)"), ); expect(setCall).toBeDefined(); // write target should be the single scoped business, not the primary expect(setCall![1][0]).toBe('business-789'); }); it('falls back to primary tenant when multi-scope includes primary', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); (tenantDBClient as any).authContext = { ...mockAuthContext, tenant: { businessId: 'business-456', roleId: 'admin' }, activeReadScope: { businessIds: ['business-456', 'business-789'] }, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_id', $1, true)"), ); expect(setCall).toBeDefined(); expect(setCall![1][0]).toBe('business-456'); }); it('uses first scoped business as write target when multi-scope excludes primary', async () => { vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any); // Primary is 'business-456' but active scope is ['business-789', 'business-abc'] // — primary is not in scope, so writing to it would violate RLS WITH CHECK. (tenantDBClient as any).authContext = { ...mockAuthContext, tenant: { businessId: 'business-456', roleId: 'admin' }, activeReadScope: { businessIds: ['business-789', 'business-abc'] }, }; (tenantDBClient as any).authContextInitialized = true; await tenantDBClient.query('SELECT 1'); const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call: any[]) => call[0].includes("set_config('app.current_business_id', $1, true)"), ); expect(setCall).toBeDefined(); expect(setCall![1][0]).toBe('business-789'); }); }); });