import express, { type ErrorRequestHandler } from 'express'; import request from 'supertest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const authMocks = vi.hoisted(() => { const state: { context: { user?: { id: string; role: 'anon' | 'authenticated' | 'project_admin' }; hasApiKey?: boolean; }; } = { context: {} }; return { state, verifyUser: vi.fn((req, _res, next) => { if ('user' in state.context) { req.user = state.context.user; } if ('hasApiKey' in state.context) { req.hasApiKey = state.context.hasApiKey; } next(); }), }; }); const proxyMocks = vi.hoisted(() => ({ forward: vi.fn(), forwardAsAdmin: vi.fn(), forwardAsUser: vi.fn(), filterHeaders: vi.fn((headers: Record) => headers), })); const databaseMocks = vi.hoisted(() => ({ getColumnTypeMap: vi.fn(), })); const socketMocks = vi.hoisted(() => ({ broadcastToRoom: vi.fn(), })); vi.mock('../../src/api/middlewares/auth.js', () => ({ verifyUser: authMocks.verifyUser, })); vi.mock('../../src/services/database/postgrest-proxy.service.js', () => ({ PostgrestProxyService: { getInstance: () => proxyMocks, filterHeaders: proxyMocks.filterHeaders, }, })); vi.mock('../../src/infra/database/database.manager.js', () => ({ DatabaseManager: { getColumnTypeMap: databaseMocks.getColumnTypeMap, }, })); vi.mock('../../src/infra/socket/socket.manager.js', () => ({ SocketManager: { getInstance: () => socketMocks, }, })); const routeErrorHandler: ErrorRequestHandler = (error, _req, res, next) => { void next; const statusCode = error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number' ? error.statusCode : 500; const message = error instanceof Error ? error.message : 'Internal server error'; res.status(statusCode).json({ message }); }; async function createApp() { vi.resetModules(); const [{ databaseRecordsRouter }, { databaseRpcRouter }] = await Promise.all([ import('../../src/api/routes/database/records.routes.js'), import('../../src/api/routes/database/rpc.routes.js'), ]); const app = express(); app.use(express.json()); app.use('/api/database/records', databaseRecordsRouter); app.use('/api/database/rpc', databaseRpcRouter); app.use(routeErrorHandler); return app; } function mockProxyResponses() { proxyMocks.forward.mockResolvedValue({ data: [{ via: 'user' }], status: 200, headers: {} }); proxyMocks.forwardAsUser.mockResolvedValue({ data: [{ via: 'user' }], status: 200, headers: {} }); proxyMocks.forwardAsAdmin.mockResolvedValue({ data: [{ via: 'project_admin' }], status: 200, headers: {}, }); } describe('Database Records Route Authentication', () => { beforeEach(() => { authMocks.state.context = {}; databaseMocks.getColumnTypeMap.mockResolvedValue({}); mockProxyResponses(); }); afterEach(() => { vi.clearAllMocks(); }); test('forwards project admin requests as PostgREST admin', async () => { authMocks.state.context = { user: { id: 'local:admin', role: 'project_admin' }, hasApiKey: false, }; const app = await createApp(); await request(app).get('/api/database/records/widgets?select=id').expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', path: '/widgets', query: expect.objectContaining({ select: 'id' }), }) ); expect(proxyMocks.forward).not.toHaveBeenCalled(); }); test('forwards API key requests as PostgREST admin', async () => { authMocks.state.context = { hasApiKey: true }; const app = await createApp(); await request(app).get('/api/database/records/widgets?select=id').expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', path: '/widgets' }) ); expect(proxyMocks.forward).not.toHaveBeenCalled(); }); test('forwards normal user requests without admin override', async () => { authMocks.state.context = { user: { id: 'user-id', role: 'authenticated' }, hasApiKey: false, }; const app = await createApp(); await request(app).get('/api/database/records/widgets?select=id').expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsUser).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', path: '/widgets' }), expect.objectContaining({ id: 'user-id', role: 'authenticated' }) ); expect(proxyMocks.forwardAsAdmin).not.toHaveBeenCalled(); }); }); describe('Database RPC Route Authentication', () => { beforeEach(() => { authMocks.state.context = {}; mockProxyResponses(); }); afterEach(() => { vi.clearAllMocks(); }); test('forwards project admin RPC calls as PostgREST admin', async () => { authMocks.state.context = { user: { id: 'local:admin', role: 'project_admin' }, hasApiKey: false, }; const app = await createApp(); await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', path: '/rpc/do_work', body: { value: 1 }, }) ); expect(proxyMocks.forward).not.toHaveBeenCalled(); }); test('forwards API key RPC calls as PostgREST admin', async () => { authMocks.state.context = { hasApiKey: true }; const app = await createApp(); await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', path: '/rpc/do_work' }) ); expect(proxyMocks.forward).not.toHaveBeenCalled(); }); test('forwards normal user RPC calls without admin override', async () => { authMocks.state.context = { user: { id: 'user-id', role: 'authenticated' }, hasApiKey: false, }; const app = await createApp(); await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200); expect(authMocks.verifyUser).toHaveBeenCalledOnce(); expect(proxyMocks.forwardAsUser).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', path: '/rpc/do_work' }), expect.objectContaining({ id: 'user-id', role: 'authenticated' }) ); expect(proxyMocks.forwardAsAdmin).not.toHaveBeenCalled(); }); });