@accounter/server
Version:
Accounter GraphQL server
488 lines (386 loc) • 19.9 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 mockContext: GraphQLModules.GlobalContext;
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;
// Request-scoped mode requires a GraphQL context (dbCleanupPlugin disposes
// the client at request end); without one the client auto-releases.
mockContext = {} as GraphQLModules.GlobalContext;
tenantDBClient = new TenantAwareDBClient(mockDBProvider, authContextProvider, mockContext);
});
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 open a request-scoped session and keep it open for reads', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
await tenantDBClient.query('SELECT 1');
// Session flow: BEGIN + RLS, then the query — no COMMIT/release until dispose
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).not.toHaveBeenCalledWith('COMMIT');
expect(mockPoolClient.release).not.toHaveBeenCalled();
await tenantDBClient.dispose();
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockPoolClient.release).toHaveBeenCalled();
});
it('should reuse the open session across sequential read queries', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
await tenantDBClient.query('SELECT 1');
await tenantDBClient.query('SELECT 2');
expect(mockPool.connect).toHaveBeenCalledTimes(1);
const beginCalls = vi.mocked(mockPoolClient.query).mock.calls.filter(call => call[0] === 'BEGIN');
expect(beginCalls).toHaveLength(1);
});
it('should commit a data-modifying query immediately and reopen the session on the next query', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
await tenantDBClient.query('UPDATE accounter_schema.charges SET user_description = $1', ['x']);
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
await tenantDBClient.query('SELECT 1');
const beginCalls = vi.mocked(mockPoolClient.query).mock.calls.filter(call => call[0] === 'BEGIN');
expect(beginCalls).toHaveLength(2);
// The pooled connection itself is reused across sessions
expect(mockPool.connect).toHaveBeenCalledTimes(1);
});
it('should roll back a failed query and recover with a fresh session', async () => {
const failure = new Error('bad query');
vi.mocked(mockPoolClient.query).mockImplementation(((text: string) =>
text === 'SELECT broken'
? Promise.reject(failure)
: Promise.resolve({ rows: [] })) as any);
await expect(tenantDBClient.query('SELECT broken')).rejects.toThrow(failure);
expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
await tenantDBClient.query('SELECT 1');
const beginCalls = vi.mocked(mockPoolClient.query).mock.calls.filter(call => call[0] === 'BEGIN');
expect(beginCalls).toHaveLength(2);
});
it('should commit and release after every query in autoRelease mode', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
tenantDBClient.autoRelease = true;
await tenantDBClient.query('SELECT 1');
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockPoolClient.release).toHaveBeenCalled();
});
it('should default to autoRelease when constructed without a GraphQL context', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
const authContextProvider = {
getAuthContext: () => Promise.resolve(mockAuthContext),
} as AuthContextProvider;
const directClient = new TenantAwareDBClient(mockDBProvider, authContextProvider);
expect(directClient.autoRelease).toBe(true);
// Nothing calls dispose() outside the request lifecycle — the connection
// must be committed and released after each query.
await directClient.query('SELECT 1');
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockPoolClient.release).toHaveBeenCalled();
});
it('should register for disposal and stay request-scoped when a context is provided', () => {
expect(tenantDBClient.autoRelease).toBe(false);
expect(mockContext.dbClientsToDispose).toContain(tenantDBClient);
});
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);
// outermost transaction scope commits promptly
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
});
});
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');
// The pooled connection is retained for the rest of the request
expect(mockPoolClient.release).not.toHaveBeenCalled();
});
});
describe('dispose', () => {
it('should commit the open session and release the client', async () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
await tenantDBClient.query('SELECT 1');
await tenantDBClient.dispose();
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
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 () => {
vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] } as any);
await tenantDBClient.query('SELECT 1');
await tenantDBClient.dispose();
await tenantDBClient.dispose();
expect(mockPoolClient.release).toHaveBeenCalledTimes(1);
});
it('should destroy the client if the final commit fails', async () => {
const commitError = new Error('Commit failed');
vi.mocked(mockPoolClient.query).mockImplementation(((text: string) =>
text === 'COMMIT' ? Promise.reject(commitError) : Promise.resolve({ rows: [] })) as any);
await tenantDBClient.query('SELECT 1');
await tenantDBClient.dispose();
// The connection state is unknown after a failed COMMIT — destroy it
expect(mockPoolClient.release).toHaveBeenCalledWith(true);
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;
// Assertions (before releasing the mutex — cleanup is deferred until the
// in-flight operation frees it, so releasing first would race them)
// 1. Client should NOT be released yet to avoid destroying an
// actively-used connection (prevents race condition)
expect(mockPoolClient.release).not.toHaveBeenCalled();
// 2. activeClient should still be set (cleanup deferred until mutex frees)
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);
// Cleanup: releasing the mutex lets the deferred cleanup run
release();
vi.useRealTimers();
});
});
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');
});
});
});