import { Pool } from 'pg'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository'; import { createPostgresReportReadRuntime, REPORT_READ_ONLY_SESSION_OPTIONS } from '../../server/reporting/report-read-runtime'; afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); function fakePool(readOnly: 'on' | 'off') { const statements: string[] = []; const pool = { query: vi.fn(async (text: string) => { statements.push(text); if (text === 'SHOW default_transaction_read_only') return { rows: [{ default_transaction_read_only: readOnly }] }; if (text.startsWith('SELECT revision')) return { rows: [{ revision: '7' }] }; throw new Error(`unexpected statement: ${text}`); }), connect: vi.fn(async () => { throw new Error('a read-only reader must not open a transaction'); }) }; return { pool: pool as unknown as Pool, statements }; } describe('Report Engine read-only database reader', () => { it('opens only on a read-only session, runs only SELECT/SHOW and schedules no Result DB rebuild', async () => { vi.useFakeTimers(); const rebuild = vi.spyOn(PostgresAssessorRepository.prototype as unknown as { scheduleResultDbRebuild: () => void }, 'scheduleResultDbRebuild'); const { pool, statements } = fakePool('on'); const repository = await PostgresAssessorRepository.openReadOnly(pool); expect(repository.revision).toBe(7); vi.runAllTimers(); expect(rebuild).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); expect(statements.every(text => /^(?:SHOW|SELECT)\b/.test(text))).toBe(true); }); it('refuses a session that could write', async () => { const { pool } = fakePool('off'); await expect(PostgresAssessorRepository.openReadOnly(pool)).rejects.toThrow(/default_transaction_read_only=on/); }); it('configures its own pool with read-only sessions and a distinct application name', async () => { const created: Array> = []; vi.spyOn(PostgresAssessorRepository, 'openReadOnly').mockImplementation(async pool => { created.push((pool as unknown as { options: Record }).options); throw new Error('stop after configuration'); }); await expect(createPostgresReportReadRuntime({ ASSESSOR_DATABASE_URL: 'postgresql://dummy:dummy@127.0.0.1:5432/assessor' })) .rejects.toThrow('stop after configuration'); expect(created[0]).toMatchObject({ options: REPORT_READ_ONLY_SESSION_OPTIONS, application_name: 'assessor-report-reader', max: 2 }); }); }); const connectionString = process.env.ASSESSOR_TEST_DATABASE_URL; describe.skipIf(!connectionString)('Report Engine read-only reader against a PostgreSQL test database', () => { it('lets PostgreSQL reject a write attempt through the report reader', async () => { const runtime = await createPostgresReportReadRuntime({ ASSESSOR_DATABASE_URL: connectionString }); const pool = new Pool({ connectionString, options: REPORT_READ_ONLY_SESSION_OPTIONS, max: 1 }); try { expect(await runtime.port.currentRevision()).toBeGreaterThanOrEqual(0); // WHERE false: even an accepted statement could not change a row; PostgreSQL still rejects it. await expect(pool.query('UPDATE assessor_repository_state SET revision = revision WHERE false')) .rejects.toMatchObject({ code: '25006' }); } finally { await pool.end(); await runtime.close(); } }); });