tests/auth-db-unavailable-hotfix.unit.test.ts
Rama main · réplica de lectura del repositorio original.
/** Regression tests for files upload auth mapping transient Postgres errors to 503. */
import { HTTPException } from 'hono/http-exception'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CapgoDrizzleQueryLogger } from '../supabase/functions/_backend/utils/drizzle_query_logger.ts'
import { onError } from '../supabase/functions/_backend/utils/on_error.ts'
import { withAuthPgRetry } from '../supabase/functions/_backend/utils/pg_auth_retry.ts'
import { getUserIdFromApikey } from '../supabase/functions/_backend/utils/pg_files.ts'
const {
executeMock,
getPgClientMock,
getDrizzleClientMock,
recordFailedAuthMock,
cloudlogMock,
} = vi.hoisted(() => ({
executeMock: vi.fn(),
getPgClientMock: vi.fn(() => ({})),
getDrizzleClientMock: vi.fn(() => ({ execute: vi.fn() })),
recordFailedAuthMock: vi.fn(async () => undefined),
cloudlogMock: vi.fn(),
}))
getDrizzleClientMock.mockImplementation(() => ({ execute: executeMock }))
vi.mock('../supabase/functions/_backend/utils/logging.ts', async importOriginal => ({
...await importOriginal<typeof import('../supabase/functions/_backend/utils/logging.ts')>(),
cloudlog: cloudlogMock,
cloudlogErr: vi.fn(),
}))
vi.mock('../supabase/functions/_backend/utils/pg.ts', async importOriginal => ({
...await importOriginal<typeof import('../supabase/functions/_backend/utils/pg.ts')>(),
closeClient: vi.fn(async () => undefined),
getDrizzleClient: getDrizzleClientMock,
getPgClient: getPgClientMock,
logPgError: vi.fn(),
}))
vi.mock('../supabase/functions/_backend/utils/rate_limit.ts', () => ({
setAPIKeyRateLimitHeaders: vi.fn(),
setFailedAuthRateLimitPolicyHeader: vi.fn(),
isAPIKeyRateLimited: vi.fn(async () => ({ limited: false })),
isIPRateLimited: vi.fn(async () => ({ limited: false })),
recordAPIKeyUsage: vi.fn(async () => ({ count: 1 })),
recordFailedAuth: recordFailedAuthMock,
}))
const { middlewareKey } = await import('../supabase/functions/_backend/utils/hono_middleware.ts')
const retryKeyRow = {
id: 1,
created_at: null,
user_id: '00000000-0000-0000-0000-000000000001',
key: 'retry-key',
key_hash: null,
rbac_id: 'rbac-1',
updated_at: null,
name: 'k',
expires_at: null,
}
async function requestWithKey(capgkey: string) {
const { Hono } = await import('hono/tiny')
const app = new Hono()
app.onError(onError('test-middleware'))
app.get('/', middlewareKey({ usePostgres: true, readOnly: false }), c => c.json({ status: 'ok' }))
return app.fetch(new Request('http://localhost/', { headers: { capgkey } }))
}
function makeContext() {
return {
get: (key: string) => (key === 'requestId' ? 'req-auth-db' : undefined),
header: vi.fn(),
res: new Response(null),
} as any
}
describe('auth database unavailable hotfix', () => {
beforeEach(() => {
vi.clearAllMocks()
getPgClientMock.mockImplementation(() => ({}))
})
it.each([
['find_apikey_by_value', 'SELECT * FROM find_apikey_by_value($1)', 'super-secret-api-key'],
['rbac_check_permission_direct', 'SELECT public.rbac_check_permission_direct($1, $2::uuid, $3::uuid, $4, $5::bigint, $6) AS allowed', 'capgo_secret_key'],
])('redacts %s api key bind parameters in drizzle query logs', (_label, query, secret) => {
new CapgoDrizzleQueryLogger().logQuery(query, [secret])
expect(cloudlogMock).toHaveBeenCalledWith({ message: `Query: ${query} -- params: [redacted]` })
expect(JSON.stringify(cloudlogMock.mock.calls)).not.toContain(secret)
})
it('retries once on transient Hyperdrive pool errors', async () => {
const operation = vi.fn()
.mockRejectedValueOnce(new Error('Timed out while waiting for an open slot in the pool.'))
.mockResolvedValueOnce('ok')
await expect(withAuthPgRetry(operation)).resolves.toBe('ok')
expect(operation).toHaveBeenCalledTimes(2)
})
it('getUserIdFromApikey returns db_error instead of null on Postgres failure', async () => {
const failure = new Error('Connection terminated unexpectedly')
executeMock.mockRejectedValue(failure)
const result = await getUserIdFromApikey(makeContext(), 'capgo_test_key', {} as any)
expect(result).toEqual({ kind: 'db_error', error: failure })
})
it('getUserIdFromApikey retries a dropped connection on a fresh primary client', async () => {
const callerClient = { id: 'caller' }
const freshClient = { id: 'fresh' }
getPgClientMock.mockImplementation(() => freshClient as any)
executeMock
.mockRejectedValueOnce(new Error('Client has encountered a connection error and is not queryable'))
.mockResolvedValueOnce({ rows: [{ get_user_id: 'user-1' }] })
const result = await getUserIdFromApikey(makeContext(), 'capgo_test_key', callerClient as any)
expect(result).toEqual({ kind: 'ok', value: 'user-1' })
expect(getDrizzleClientMock.mock.calls.map(call => (call as unknown[])[0])).toEqual([callerClient, freshClient])
expect(getPgClientMock).toHaveBeenCalledWith(expect.anything(), false)
})
it('middlewareKey returns 503 database_unavailable on checkKeyPg failure without recording failed auth', async () => {
executeMock.mockRejectedValue(new Error('Timed out while waiting for an open slot in the pool.'))
const response = await requestWithKey('valid-looking-key')
expect(response.status).toBe(503)
const body = await response.json() as { error: string }
expect(body.error).toBe('database_unavailable')
expect(response.headers.get('Retry-After')).toBe('2')
expect(recordFailedAuthMock).not.toHaveBeenCalled()
})
it('middlewareKey still returns 401 invalid_apikey for a missing key', async () => {
executeMock.mockResolvedValue({ rows: [] })
const response = await requestWithKey('missing-key')
expect(response.status).toBe(401)
const body = await response.json() as { error: string }
expect(body.error).toBe('invalid_apikey')
expect(recordFailedAuthMock).toHaveBeenCalled()
})
it('middlewareKey retries when getPgClient throws a transient error', async () => {
getPgClientMock
.mockImplementationOnce(() => {
throw new Error('timeout exceeded when trying to connect')
})
.mockImplementation(() => ({}))
executeMock.mockResolvedValueOnce({ rows: [retryKeyRow] })
const response = await requestWithKey('retry-key')
expect(response.status).toBe(200)
expect(getPgClientMock).toHaveBeenCalledTimes(2)
expect(recordFailedAuthMock).not.toHaveBeenCalled()
})
it('middlewareKey retries checkKeyPg once before succeeding', async () => {
executeMock
.mockRejectedValueOnce(new Error('Connection terminated unexpectedly'))
.mockResolvedValueOnce({ rows: [retryKeyRow] })
const response = await requestWithKey('retry-key')
expect(response.status).toBe(200)
expect(executeMock).toHaveBeenCalledTimes(2)
expect(recordFailedAuthMock).not.toHaveBeenCalled()
})
it('throwDatabaseUnavailable uses database_unavailable without transient cause rewrite', async () => {
const { throwDatabaseUnavailable } = await import('../supabase/functions/_backend/utils/pg_auth_lookup.ts')
const ctx = makeContext()
expect.assertions(4)
try {
throwDatabaseUnavailable(ctx, 'unit-test', new Error('Connection terminated unexpectedly'))
}
catch (error) {
expect(error).toBeInstanceOf(HTTPException)
const httpError = error as HTTPException
expect(httpError.status).toBe(503)
expect((httpError.cause as { error?: string }).error).toBe('database_unavailable')
expect((httpError.cause as { originalCause?: unknown }).originalCause).toBeUndefined()
}
})
})