@accounter/server
Version:
Accounter GraphQL server
406 lines (325 loc) • 16.1 kB
text/typescript
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');
});
});
});