fix: Pool-Connection-Isolation in withTenant, SQL-Injection-Schutz, ClickHouse env-var-Check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
import { createClient } from '@clickhouse/client'
|
import { createClient } from '@clickhouse/client'
|
||||||
|
|
||||||
export const clickhouse = createClient({
|
const url = process.env.CLICKHOUSE_URL
|
||||||
url: process.env.CLICKHOUSE_URL,
|
const username = process.env.CLICKHOUSE_USER
|
||||||
username: process.env.CLICKHOUSE_USER,
|
const password = process.env.CLICKHOUSE_PASSWORD
|
||||||
password: process.env.CLICKHOUSE_PASSWORD,
|
|
||||||
database: 'newsletter',
|
if (!url || !username || !password) {
|
||||||
})
|
throw new Error('CLICKHOUSE_URL, CLICKHOUSE_USER und CLICKHOUSE_PASSWORD müssen gesetzt sein')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clickhouse = createClient({ url, username, password, database: 'newsletter' })
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
// Pool mocken — gibt einen fake PoolClient zurück
|
||||||
db: {
|
const mockClient = vi.hoisted(() => ({
|
||||||
execute: vi.fn(),
|
query: vi.fn(),
|
||||||
query: vi.fn().mockResolvedValue([{ id: '1' }]),
|
release: vi.fn(),
|
||||||
},
|
}))
|
||||||
|
|
||||||
|
vi.mock('pg', () => ({
|
||||||
|
Pool: vi.fn().mockImplementation(() => ({
|
||||||
|
connect: vi.fn().mockResolvedValue(mockClient),
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { withTenant } from './tenant'
|
import { withTenant } from './tenant'
|
||||||
import { db } from './client'
|
|
||||||
|
|
||||||
describe('withTenant', () => {
|
describe('withTenant', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockClient.query.mockResolvedValue({ rows: [] })
|
||||||
|
})
|
||||||
|
|
||||||
it('setzt search_path auf tenant-Schema', async () => {
|
it('setzt search_path auf tenant-Schema', async () => {
|
||||||
await withTenant('abc123', () => db.query('SELECT 1', []))
|
await withTenant('abc123', async (client) => {
|
||||||
expect(db.execute).toHaveBeenCalledWith('SET search_path = tenant_abc123, public')
|
await client.query('SELECT 1', [])
|
||||||
|
})
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('SET search_path = tenant_abc123, public')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setzt search_path zurück auf public nach Ausführung', async () => {
|
it('setzt search_path zurück auf public nach Ausführung', async () => {
|
||||||
await withTenant('abc123', () => db.query('SELECT 1', []))
|
await withTenant('abc123', async (client) => {
|
||||||
expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public')
|
await client.query('SELECT 1', [])
|
||||||
|
})
|
||||||
|
expect(mockClient.query).toHaveBeenLastCalledWith('SET search_path = public')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setzt search_path zurück auch bei Fehler', async () => {
|
it('setzt search_path zurück auch bei Fehler', async () => {
|
||||||
const fn = vi.fn().mockRejectedValue(new Error('DB-Fehler'))
|
await expect(
|
||||||
await expect(withTenant('abc123', fn)).rejects.toThrow('DB-Fehler')
|
withTenant('abc123', async () => { throw new Error('DB-Fehler') })
|
||||||
expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public')
|
).rejects.toThrow('DB-Fehler')
|
||||||
|
expect(mockClient.query).toHaveBeenLastCalledWith('SET search_path = public')
|
||||||
|
expect(mockClient.release).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gibt Rückgabewert der Funktion zurück', async () => {
|
it('gibt Rückgabewert der Funktion zurück', async () => {
|
||||||
const result = await withTenant('abc123', async () => 'testdata')
|
const result = await withTenant('abc123', async () => 'testdata')
|
||||||
expect(result).toBe('testdata')
|
expect(result).toBe('testdata')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('wirft bei ungültiger tenantId', async () => {
|
||||||
|
await expect(
|
||||||
|
withTenant('invalid-tenant!', async () => 'x')
|
||||||
|
).rejects.toThrow('Ungültige tenantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt dieselbe Client-Instanz an fn weiter (Connection-Isolation)', async () => {
|
||||||
|
let capturedClient: unknown
|
||||||
|
await withTenant('tenant1', async (client) => {
|
||||||
|
capturedClient = client
|
||||||
|
})
|
||||||
|
// Client muss nach withTenant released worden sein
|
||||||
|
expect(mockClient.release).toHaveBeenCalledOnce()
|
||||||
|
expect(capturedClient).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { db } from './client'
|
import { Pool, type QueryResultRow } from 'pg'
|
||||||
|
|
||||||
export async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
interface TenantClient {
|
||||||
|
execute: (sql: string) => Promise<void>
|
||||||
|
query: <T extends QueryResultRow = Record<string, unknown>>(sql: string, params: unknown[]) => Promise<T[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTenant<T>(
|
||||||
|
tenantId: string,
|
||||||
|
fn: (client: TenantClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
if (!/^[a-z0-9_]+$/.test(tenantId)) {
|
||||||
|
throw new Error(`Ungültige tenantId: ${tenantId}`)
|
||||||
|
}
|
||||||
const schema = `tenant_${tenantId}`
|
const schema = `tenant_${tenantId}`
|
||||||
await db.execute(`SET search_path = ${schema}, public`)
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
return await fn()
|
await client.query(`SET search_path = ${schema}, public`)
|
||||||
|
const tenantClient: TenantClient = {
|
||||||
|
execute: async (sql) => { await client.query(sql) },
|
||||||
|
query: async <T extends QueryResultRow = Record<string, unknown>>(sql: string, params: unknown[]) =>
|
||||||
|
client.query<T>(sql, params).then((r) => r.rows),
|
||||||
|
}
|
||||||
|
return await fn(tenantClient)
|
||||||
} finally {
|
} finally {
|
||||||
await db.execute(`SET search_path = public`)
|
await client.query('SET search_path = public')
|
||||||
|
client.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user