diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts new file mode 100644 index 0000000..cc839d6 --- /dev/null +++ b/src/allowlist/index.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isQueryAllowed } from './index' +import type { DataSource } from '../types' +import type { StarbaseDBConfiguration } from '../handler' + +let mockDataSource: DataSource + +function createConfig( + overrides: Partial = {} +): StarbaseDBConfiguration { + return { + outerbaseApiKey: 'mock-api-key', + role: 'user', + features: { allowlist: true, rls: true, rest: true }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + + mockDataSource = { + source: 'external', + external: { dialect: 'sqlite' }, + rpc: { executeQuery: vi.fn() }, + } as any +}) + +describe('Allowlist Module', () => { + it('allows any query when the allowlist feature is disabled', async () => { + const result = await isQueryAllowed({ + sql: 'DROP TABLE users', + isEnabled: false, + dataSource: mockDataSource, + config: createConfig(), + }) + + expect(result).toBe(true) + expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('allows any query for the admin role', async () => { + const result = await isQueryAllowed({ + sql: 'DROP TABLE users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig({ role: 'admin' }), + }) + + expect(result).toBe(true) + expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('allows a query whose AST matches an allowlist entry', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'external' }, + ]) + + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + + expect(result).toBe(true) + }) + + it('treats a trailing semicolon as equivalent to the allowlist entry', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { sql_statement: 'SELECT * FROM users;', source: 'external' }, + ]) + + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + + expect(result).toBe(true) + }) + + it('ignores allowlist rows from other sources', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'other-source' }, + ]) + + await expect( + isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + }) + + it('rejects and records queries that are not on the allowlist', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'external' }, + ]) + + await expect( + isQueryAllowed({ + sql: 'DELETE FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(2) + const insertCall = (mockDataSource.rpc.executeQuery as any).mock + .calls[1][0] + expect(insertCall.sql).toContain('INSERT INTO tmp_allowlist_rejections') + expect(insertCall.params).toEqual(['DELETE FROM users', 'external']) + }) + + it('returns an Error object when no SQL is provided', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([]) + + const result = await isQueryAllowed({ + sql: '', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe( + 'No SQL provided for allowlist check' + ) + }) + + it('returns an empty allowlist when loading it fails', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockRejectedValue( + new Error('db unavailable') + ) + + await expect( + isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + }) + + it('does not fail the request when recording a rejection fails', async () => { + const executeQuery = mockDataSource.rpc.executeQuery as any + executeQuery.mockImplementation(({ sql }: { sql: string }) => { + if (sql.startsWith('SELECT sql_statement')) { + return Promise.resolve([ + { + sql_statement: 'SELECT * FROM users', + source: 'external', + }, + ]) + } + return Promise.reject(new Error('insert failed')) + }) + + await expect( + isQueryAllowed({ + sql: 'DELETE FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + }) + + it('rejects queries that partially match an allowlist entry', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { + sql_statement: 'SELECT id, name FROM users WHERE id = 1', + source: 'external', + }, + ]) + + await expect( + isQueryAllowed({ + sql: 'SELECT id, name FROM users WHERE id = 2', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + }) + + it('rejects queries of a different statement type than the entry', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'external' }, + ]) + + await expect( + isQueryAllowed({ + sql: 'INSERT INTO users VALUES (1)', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow('Query not allowed') + }) + + it('rethrows parser errors for invalid SQL', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue([]) + + await expect( + isQueryAllowed({ + sql: '((( not valid sql ))]', + isEnabled: true, + dataSource: mockDataSource, + config: createConfig(), + }) + ).rejects.toThrow() + }) +}) diff --git a/src/do.advanced.test.ts b/src/do.advanced.test.ts new file mode 100644 index 0000000..53e7e67 --- /dev/null +++ b/src/do.advanced.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { StarbaseDBDurableObject } from './do' + +vi.mock('cloudflare:workers', () => { + return { + DurableObject: class MockDurableObject { + ctx: any + env: any + constructor(ctx: any, env: any) { + this.ctx = ctx + this.env = env + } + }, + } +}) + +declare global { + var WebSocket: any + var WebSocketPair: any + var Response: any +} + +global.WebSocket = class { + static READY_STATE_CONNECTING = 0 + static READY_STATE_OPEN = 1 + static READY_STATE_CLOSING = 2 + static READY_STATE_CLOSED = 3 + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = global.WebSocket.CONNECTING + send = vi.fn() + close = vi.fn() + accept = vi.fn() + addEventListener = vi.fn() + + constructor(public url?: string) {} +} + +global.WebSocketPair = vi.fn(() => { + const client = new global.WebSocket('ws://localhost') + const server = new global.WebSocket('ws://localhost') + return { 0: client, 1: server } +}) + +global.Response = class { + body: any + status: any + webSocket: any + constructor(body?: any, init?: any) { + this.body = body + this.status = init?.status ?? 200 + this.webSocket = init?.webSocket + } +} + +function createCursor(rows: Record[] = []) { + return { + columnNames: ['id', 'name'], + raw: vi.fn().mockReturnValue([[1, 'Alice']]), + toArray: vi.fn().mockReturnValue(rows), + rowsRead: 2, + rowsWritten: 0, + } +} + +function createInstance(cursor: any = createCursor()) { + const storage = { + sql: { + exec: vi.fn().mockReturnValue(cursor), + databaseSize: 1024, + }, + getAlarm: vi.fn().mockResolvedValue(null), + setAlarm: vi.fn().mockResolvedValue(undefined), + deleteAlarm: vi.fn().mockResolvedValue(undefined), + getTags: vi.fn().mockReturnValue([]), + } + + const ctx = { storage, waitUntil: vi.fn(), getTags: vi.fn(() => []) } + const env = { CLIENT_AUTHORIZATION_TOKEN: 'client-token' } + + const instance = new StarbaseDBDurableObject(ctx as any, env as any) + return { instance, storage, ctx, env } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('StarbaseDBDurableObject - advanced behaviors', () => { + it('exposes bound rpc methods from init()', async () => { + const { instance } = createInstance() + const rpc = instance.init() as any + + expect(Object.keys(rpc).sort()).toEqual([ + 'deleteAlarm', + 'executeQuery', + 'getAlarm', + 'getStatistics', + 'setAlarm', + ]) + + await rpc.getAlarm() + expect((instance as any).storage.getAlarm).toHaveBeenCalled() + }) + + it('getAlarm returns the storage alarm', async () => { + const { instance, storage } = createInstance() + ;(storage.getAlarm as any).mockResolvedValue(12345) + + expect(await instance.getAlarm()).toBe(12345) + }) + + it('setAlarm accepts Date and number inputs and clamps to the future', async () => { + const { instance, storage } = createInstance() + + const date = new Date(Date.now() + 5000) + await instance.setAlarm(date) + expect(storage.setAlarm).toHaveBeenLastCalledWith( + date.getTime(), + undefined + ) + + await instance.setAlarm(Date.now() + 20000) + expect(storage.setAlarm).toHaveBeenLastCalledWith( + Date.now() + 20000, + undefined + ) + + await instance.setAlarm(Date.now() - 100000) + const finalTime = (storage.setAlarm as any).mock.calls[2][0] + expect(finalTime).toBeGreaterThanOrEqual(Date.now() + 900) + }) + + it('setAlarm rethrows storage failures', async () => { + const { instance, storage } = createInstance() + ;(storage.setAlarm as any).mockRejectedValue(new Error('nope')) + + await expect(instance.setAlarm(12345)).rejects.toThrow('nope') + }) + + it('deleteAlarm forwards options to storage', async () => { + const { instance, storage } = createInstance() + await instance.deleteAlarm({ allowUnfinished: true } as any) + expect(storage.deleteAlarm).toHaveBeenCalledWith({ + allowUnfinished: true, + }) + }) + + it('getStatistics reports database size, connections, and query count', async () => { + const cursor = createCursor([{ count: 7 }]) + const { instance } = createInstance(cursor) + ;(instance as any).connections.set('session', new global.WebSocket()) + + const stats = await instance.getStatistics() + + expect(stats.databaseSize).toBe(1024) + expect(stats.activeConnections).toBe(1) + expect(stats.recentQueries).toBe(7) + }) + + it('getStatistics falls back to zero when the query log is empty', async () => { + const cursor = createCursor([]) + const { instance } = createInstance(cursor) + + const stats = await instance.getStatistics() + expect(stats.recentQueries).toBe(0) + }) + + it('fetch rejects non-websocket upgrades to /socket', async () => { + const { instance } = createInstance() + const response = await instance.fetch( + new Request('http://do/socket') as any + ) + + expect(response.status).toBe(400) + }) + + it('fetch upgrades websocket connections and registers the session', async () => { + const { instance } = createInstance() + const response = await instance.fetch( + new Request('http://do/socket?sessionId=my-session', { + headers: { upgrade: 'websocket' }, + }) as any + ) + + expect(response.status).toBe(101) + expect(response.webSocket).toBeDefined() + expect((instance as any).connections.has('my-session')).toBe(true) + }) + + it('clientConnected generates a session id when none is provided', async () => { + const { instance } = createInstance() + const response = await instance.clientConnected() + + expect(response.status).toBe(101) + expect((instance as any).connections.size).toBe(1) + const server = [...(instance as any).connections.values()][0] + expect(server.addEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function) + ) + expect(server.addEventListener).toHaveBeenCalledWith( + 'error', + expect.any(Function) + ) + }) + + it('broadcasts to every connection and cleans up broken ones', async () => { + const { instance } = createInstance() + const good = new global.WebSocket() + const bad = new global.WebSocket() + ;(bad.send as any).mockImplementation(() => { + throw new Error('dead socket') + }) + ;(instance as any).connections.set('good', good) + ;(instance as any).connections.set('bad', bad) + + const response = await instance.fetch( + new Request('http://do/socket/broadcast', { + method: 'POST', + body: JSON.stringify({ event: 'hello' }), + }) as any + ) + + expect(response.status).toBe(200) + expect(good.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'hello' }) + ) + expect((instance as any).connections.has('bad')).toBe(false) + expect((instance as any).connections.has('good')).toBe(true) + }) + + it('broadcast targets only the requested session when one is specified', async () => { + const { instance } = createInstance() + const first = new global.WebSocket() + const second = new global.WebSocket() + ;(instance as any).connections.set('first', first) + ;(instance as any).connections.set('second', second) + + await instance.fetch( + new Request('http://do/socket/broadcast?sessionId=second', { + method: 'POST', + body: JSON.stringify({ event: 'private' }), + }) as any + ) + + expect(first.send).not.toHaveBeenCalled() + expect(second.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'private' }) + ) + }) + + it('fetch returns 400 for unknown operations', async () => { + const { instance } = createInstance() + const response = await instance.fetch( + new Request('http://do/other') as any + ) + + expect(response.status).toBe(400) + }) + + it('webSocketMessage executes queries for query actions', async () => { + const { instance } = createInstance() + const spy = vi + .spyOn(instance as any, 'executeTransaction') + .mockResolvedValue([{ id: 1 }]) + const ws = new global.WebSocket() + + await (instance as any).webSocketMessage( + ws, + JSON.stringify({ sql: 'SELECT 1', params: [], action: 'query' }) + ) + + expect(spy).toHaveBeenCalledWith( + [{ sql: 'SELECT 1', params: [] }], + false + ) + expect(ws.send).toHaveBeenCalledWith(JSON.stringify([{ id: 1 }])) + }) + + it('webSocketMessage ignores non-query actions', async () => { + const { instance } = createInstance() + const spy = vi.spyOn(instance as any, 'executeTransaction') + const ws = new global.WebSocket() + + await (instance as any).webSocketMessage( + ws, + JSON.stringify({ action: 'other' }) + ) + + expect(spy).not.toHaveBeenCalled() + }) + + it('webSocketClose closes the socket and removes tagged sessions', async () => { + const { instance, ctx } = createInstance() + ;(ctx.getTags as any).mockReturnValue(['session-1']) + const ws = new global.WebSocket() + ;(instance as any).connections.set('session-1', ws) + + await (instance as any).webSocketClose(ws, 1000, 'done', true) + + expect(ws.close).toHaveBeenCalledWith( + 1000, + 'StarbaseDB is closing WebSocket connection' + ) + expect((instance as any).connections.has('session-1')).toBe(false) + }) + + it('webSocketClose keeps sessions without tags', async () => { + const { instance } = createInstance() + const ws = new global.WebSocket() + ;(instance as any).connections.set('session-2', ws) + + await (instance as any).webSocketClose(ws, 1000, 'done', true) + + expect((instance as any).connections.has('session-2')).toBe(true) + }) + + it('executeQuery returns raw rows with metadata when isRaw is set', async () => { + const cursor = createCursor() + const { instance } = createInstance(cursor) + + const raw = await instance.executeQuery({ + sql: 'SELECT 1', + isRaw: true, + }) + + expect(raw).toEqual({ + columns: cursor.columnNames, + rows: [[1, 'Alice']], + meta: { rows_read: 2, rows_written: 0 }, + }) + }) + + it('executeQuery forwards params to the sql cursor', async () => { + const { instance, storage } = createInstance() + + await instance.executeQuery({ + sql: 'SELECT * FROM users WHERE id = ?', + params: [1], + }) + expect(storage.sql.exec).toHaveBeenCalledWith( + 'SELECT * FROM users WHERE id = ?', + 1 + ) + + await instance.executeQuery({ sql: 'SELECT 1' }) + expect(storage.sql.exec).toHaveBeenLastCalledWith('SELECT 1') + }) + + it('executeQuery rethrows sql execution failures', async () => { + const { instance, storage } = createInstance() + ;(storage.sql.exec as any).mockImplementation(() => { + throw new Error('SQL parse error') + }) + + await expect(instance.executeQuery({ sql: 'BAD' })).rejects.toThrow( + 'SQL parse error' + ) + }) + + it('executeTransaction aggregates results across queries', async () => { + const { instance } = createInstance() + + const results = await instance.executeTransaction( + [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }], + true + ) + + expect(results).toHaveLength(2) + }) + + it('executeTransaction rolls forward the error when a query fails', async () => { + const { instance, storage } = createInstance() + ;(storage.sql.exec as any).mockImplementation(() => { + throw new Error('constraint violation') + }) + + await expect( + instance.executeTransaction([{ sql: 'BAD' }], false) + ).rejects.toThrow('constraint violation') + }) + + it('alarm exits early when there are no active cron tasks', async () => { + const cursor = createCursor([]) + const { instance } = createInstance(cursor) + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + + await (instance as any).alarm() + + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('alarm calls the cron callback for active tasks', async () => { + const cursor = createCursor([ + { callback_host: 'https://worker.example.com' }, + ]) + const { instance } = createInstance(cursor) + const fetchSpy = vi.fn().mockResolvedValue(new Response('ok')) + vi.stubGlobal('fetch', fetchSpy) + + await (instance as any).alarm() + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://worker.example.com/cron/callback', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('alarm reschedules itself when the callback fails', async () => { + const cursor = createCursor([ + { callback_host: 'https://worker.example.com' }, + ]) + const { instance, storage } = createInstance(cursor) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('down'))) + + await (instance as any).alarm() + + expect(storage.setAlarm).toHaveBeenCalled() + }) + + it('alarm reschedules itself when reading the tasks fails', async () => { + const { instance, storage } = createInstance() + vi.spyOn(instance as any, 'executeQuery').mockRejectedValue( + new Error('sql down') + ) + vi.stubGlobal('fetch', vi.fn()) + + await (instance as any).alarm() + + expect(storage.setAlarm).toHaveBeenCalled() + }) +}) diff --git a/src/handler.advanced.test.ts b/src/handler.advanced.test.ts new file mode 100644 index 0000000..1f6d5ca --- /dev/null +++ b/src/handler.advanced.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StarbaseDB } from './handler' +import { executeQuery, executeTransaction } from './operation' +import { LiteREST } from './literest' +import { corsPreflight } from './cors' +import { handleApiRequest } from './api' +import { dumpDatabaseRoute } from './export/dump' +import { exportTableToJsonRoute } from './export/json' +import { exportTableToCsvRoute } from './export/csv' +import { importDumpRoute } from './import/dump' +import { importTableFromJsonRoute } from './import/json' +import { importTableFromCsvRoute } from './import/csv' +import type { DataSource } from './types' + +vi.mock('./utils', () => ({ + createResponse: vi.fn( + (data, message, status) => + new Response(JSON.stringify({ result: data, error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ), +})) + +vi.mock('./operation', () => ({ + executeQuery: vi.fn().mockResolvedValue([{ id: 1 }]), + executeTransaction: vi.fn().mockResolvedValue([[{ id: 1 }]]), +})) + +vi.mock('./literest', () => ({ + LiteREST: vi.fn().mockImplementation(() => ({ + handleRequest: vi.fn().mockResolvedValue(new Response('rest-result')), + })), +})) + +vi.mock('./cors', () => ({ + corsPreflight: vi.fn(), +})) + +vi.mock('./api', () => ({ + handleApiRequest: vi + .fn() + .mockResolvedValue(new Response('api-result', { status: 200 })), +})) + +vi.mock('./export/dump', () => ({ + dumpDatabaseRoute: vi + .fn() + .mockResolvedValue(new Response('dump-result', { status: 200 })), +})) + +vi.mock('./export/json', () => ({ + exportTableToJsonRoute: vi + .fn() + .mockResolvedValue(new Response('json-result', { status: 200 })), +})) + +vi.mock('./export/csv', () => ({ + exportTableToCsvRoute: vi + .fn() + .mockResolvedValue(new Response('csv-result', { status: 200 })), +})) + +vi.mock('./import/dump', () => ({ + importDumpRoute: vi + .fn() + .mockResolvedValue(new Response('import-dump-result', { status: 200 })), +})) + +vi.mock('./import/json', () => ({ + importTableFromJsonRoute: vi + .fn() + .mockResolvedValue(new Response('import-json-result', { status: 200 })), +})) + +vi.mock('./import/csv', () => ({ + importTableFromCsvRoute: vi + .fn() + .mockResolvedValue(new Response('import-csv-result', { status: 200 })), +})) + +let mockDataSource: DataSource + +const ctx = { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), +} as any + +beforeEach(() => { + vi.clearAllMocks() + ;(corsPreflight as any).mockReturnValue(undefined) + + mockDataSource = { + source: 'internal', + rpc: { executeQuery: vi.fn().mockResolvedValue([{ id: 1 }]) }, + } as any +}) + +function createStarbase(overrides: any = {}) { + return new StarbaseDB({ + dataSource: mockDataSource, + config: { + outerbaseApiKey: undefined, + role: 'admin', + features: { + allowlist: false, + rls: false, + rest: true, + export: true, + import: true, + }, + }, + plugins: [], + ...overrides, + }) +} + +function jsonResponse(response: Response): Promise { + return response.json() as any +} + +describe('StarbaseDB handler - advanced behaviors', () => { + it('rejects construction of an external source without connection details', () => { + expect( + () => + new StarbaseDB({ + dataSource: { source: 'external' } as any, + config: { role: 'admin' }, + plugins: [], + }) + ).toThrow('No external data sources available.') + }) + + it('returns 404 for unknown routes', async () => { + const starbase = createStarbase() + const response = await starbase.handle( + new Request('http://localhost/nope'), + ctx + ) + + expect(response.status).toBe(404) + expect((await jsonResponse(response)).error).toBe('Not found') + }) + + it('returns the cors preflight response for OPTIONS requests', async () => { + const preflight = new Response(null, { status: 204 }) + ;(corsPreflight as any).mockReturnValue(preflight) + + const response = await createStarbase().handle( + new Request('http://localhost/query', { method: 'OPTIONS' }), + ctx + ) + + expect(response).toBe(preflight) + }) + + it('reports dialect information on /status/database', async () => { + mockDataSource.external = { dialect: 'postgresql' } as any + const response = await createStarbase().handle( + new Request('http://localhost/status/database'), + ctx + ) + + expect(response.status).toBe(200) + const body = await jsonResponse(response) + expect(body.result.dialects.external).toBe('postgresql') + expect(body.result.dialects.hyperdrive).toBe('postgresql') + }) + + it('proxies the cloudflare trace endpoint', async () => { + const traceResponse = new Response('trace-body', { + headers: { 'x-trace': '1' }, + }) + const fetchSpy = vi.fn().mockResolvedValue(traceResponse) + vi.stubGlobal('fetch', fetchSpy) + + const response = await createStarbase().handle( + new Request('http://localhost/status/trace'), + ctx + ) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://cloudflare.com/cdn-cgi/trace' + ) + expect(await response.text()).toBe('trace-body') + vi.unstubAllGlobals() + }) + + it('routes /rest/* requests to LiteREST when the feature is enabled', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/rest/users'), + ctx + ) + + expect(await response.text()).toBe('rest-result') + }) + + it('executes single queries posted to /query', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql: 'SELECT * FROM users' }), + }), + ctx + ) + + expect(response.status).toBe(200) + expect(executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sql: 'SELECT * FROM users', + isRaw: false, + }) + ) + }) + + it('passes isRaw for /query/raw requests', async () => { + await createStarbase().handle( + new Request('http://localhost/query/raw', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql: 'SELECT * FROM users' }), + }), + ctx + ) + + expect(executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ isRaw: true }) + ) + }) + + it('rejects non-json content types on the query route', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'plain', + }), + ctx + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Content-Type must be application/json.' + ) + }) + + it('rejects empty sql fields on the query route', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql: ' ' }), + }), + ctx + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Invalid or empty "sql" field.' + ) + }) + + it('rejects invalid params fields on the query route', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql: 'SELECT 1', params: 'bad' }), + }), + ctx + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Invalid "params" field. Must be an array or object.' + ) + }) + + it('executes transactions when the transaction array is provided', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction: [ + { sql: 'INSERT INTO users VALUES (1)' }, + { sql: 'INSERT INTO users VALUES (2)', params: [2] }, + ], + }), + }), + ctx + ) + + expect(response.status).toBe(200) + expect(executeTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + isRaw: false, + queries: [ + { sql: 'INSERT INTO users VALUES (1)', params: undefined }, + { sql: 'INSERT INTO users VALUES (2)', params: [2] }, + ], + }) + ) + }) + + it('returns 500 when a transaction entry has an empty sql field', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction: [{ sql: ' ' }], + }), + }), + ctx + ) + + expect(response.status).toBe(500) + expect((await jsonResponse(response)).error).toBe( + 'Invalid or empty "sql" field in transaction.' + ) + }) + + it('returns 500 when a transaction entry has invalid params', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction: [{ sql: 'SELECT 1', params: 42 }], + }), + }), + ctx + ) + + expect(response.status).toBe(500) + expect((await jsonResponse(response)).error).toBe( + 'Invalid "params" field in transaction. Must be an array or object.' + ) + }) + + it('returns 500 through the error handler when query parsing fails', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json', + }), + ctx + ) + + expect(response.status).toBe(500) + }) + + it('returns 400 for export/import routes on non-internal sources', async () => { + mockDataSource.source = 'external' + mockDataSource.external = { dialect: 'postgresql' } as any + const starbase = createStarbase() + + const response = await starbase.handle( + new Request('http://localhost/export/dump'), + ctx + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Function is only available for internal data source.' + ) + }) + + it('serves the export dump route for internal sources', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/export/dump'), + ctx + ) + + expect(await response.text()).toBe('dump-result') + expect(dumpDatabaseRoute).toHaveBeenCalled() + }) + + it('serves json and csv table exports', async () => { + const starbase = createStarbase() + + const jsonResponse = await starbase.handle( + new Request('http://localhost/export/json/users'), + ctx + ) + expect(await jsonResponse.text()).toBe('json-result') + expect(exportTableToJsonRoute).toHaveBeenCalledWith( + 'users', + mockDataSource, + starbase['config'] + ) + + const csvResponse = await starbase.handle( + new Request('http://localhost/export/csv/users'), + ctx + ) + expect(await csvResponse.text()).toBe('csv-result') + expect(exportTableToCsvRoute).toHaveBeenCalled() + }) + + it('requires a table name for export routes', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/export/json/%20'), + ctx + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Table name is required' + ) + }) + + it('serves the import routes', async () => { + const starbase = createStarbase() + + const dumpResponse = await starbase.handle( + new Request('http://localhost/import/dump', { method: 'POST' }), + ctx + ) + expect(await dumpResponse.text()).toBe('import-dump-result') + expect(importDumpRoute).toHaveBeenCalled() + + const jsonResponse = await starbase.handle( + new Request('http://localhost/import/json/users', { + method: 'POST', + }), + ctx + ) + expect(await jsonResponse.text()).toBe('import-json-result') + expect(importTableFromJsonRoute).toHaveBeenCalled() + + const csvResponse = await starbase.handle( + new Request('http://localhost/import/csv/users', { + method: 'POST', + }), + ctx + ) + expect(await csvResponse.text()).toBe('import-csv-result') + expect(importTableFromCsvRoute).toHaveBeenCalled() + }) + + it('routes /api/* requests to the api handler', async () => { + const response = await createStarbase().handle( + new Request('http://localhost/api/v1/thing'), + ctx + ) + + expect(await response.text()).toBe('api-result') + expect(handleApiRequest).toHaveBeenCalled() + }) + + it('schedules cache expiration via waitUntil on handle', async () => { + await createStarbase().handle( + new Request('http://localhost/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql: 'SELECT 1' }), + }), + ctx + ) + + expect(ctx.waitUntil).toHaveBeenCalled() + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sql: expect.stringContaining('tmp_cache'), + }) + ) + }) + + it('handlePreAuth serves authless plugin routes directly', async () => { + const authlessPlugin = { + name: 'public-plugin', + opts: { requiresAuth: false }, + pathPrefix: '/public/*', + register: vi.fn().mockResolvedValue(undefined), + } + + const starbase = createStarbase({ plugins: [authlessPlugin as any] }) + const response = await starbase.handlePreAuth( + new Request('http://localhost/public/page'), + ctx + ) + + expect(response).toBeDefined() + expect(response!.status).toBe(404) + }) + + it('handlePreAuth returns undefined for authenticated plugin routes', async () => { + const authPlugin = { + name: 'auth-plugin', + opts: { requiresAuth: true }, + pathPrefix: '/private/*', + register: vi.fn().mockResolvedValue(undefined), + } + + const starbase = createStarbase({ plugins: [authPlugin as any] }) + const result = await starbase.handlePreAuth( + new Request('http://localhost/private/page'), + ctx + ) + + expect(result).toBeUndefined() + }) + + it('handlePreAuth ignores authless plugins without a pathPrefix', async () => { + const noPrefixPlugin = { + name: 'no-prefix', + opts: { requiresAuth: false }, + register: vi.fn().mockResolvedValue(undefined), + } + + const starbase = createStarbase({ plugins: [noPrefixPlugin as any] }) + const result = await starbase.handlePreAuth( + new Request('http://localhost/anything'), + ctx + ) + + expect(result).toBeUndefined() + }) +}) diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts new file mode 100644 index 0000000..6743161 --- /dev/null +++ b/src/import/csv.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { importTableFromCsvRoute } from './csv' +import { executeOperation } from '../export' +import type { DataSource } from '../types' +import type { StarbaseDBConfiguration } from '../handler' + +vi.mock('../export', () => ({ + executeOperation: vi.fn(), +})) + +vi.mock('../utils', () => ({ + createResponse: vi.fn( + (data, message, status) => + new Response(JSON.stringify({ result: data, error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ), +})) + +let mockDataSource: DataSource +let mockConfig: StarbaseDBConfiguration + +beforeEach(() => { + vi.clearAllMocks() + ;(executeOperation as any).mockReset() + + mockDataSource = { + source: 'external', + external: { dialect: 'sqlite' }, + rpc: { executeQuery: vi.fn() }, + } as any + + mockConfig = { + outerbaseApiKey: 'mock-api-key', + role: 'admin', + features: { allowlist: true, rls: true, rest: true }, + } +}) + +function jsonResponse(response: Response): Promise<{ + result?: any + error?: string +}> { + return response.json() as any +} + +describe('CSV Import Module', () => { + it('should return 400 for unsupported Content-Type', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'id,name', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Unsupported Content-Type' + ) + }) + + it('should return 400 when request body is empty', async () => { + const request = new Request('http://localhost/import', { + method: 'GET', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Request body is empty' + ) + }) + + it('should import records from JSON-wrapped CSV data', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'id,name\n1,Alice\n2,Bob' }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const body = await jsonResponse(response) + expect(body.result.message).toBe( + 'Imported 2 out of 2 records successfully. 0 records failed.' + ) + expect(body.result.failedStatements).toEqual([]) + expect(executeOperation).toHaveBeenCalledTimes(2) + const [operations] = (executeOperation as any).mock.calls[0] + expect(operations[0].sql).toBe( + 'INSERT INTO users (id, name) VALUES (?, ?)' + ) + expect(operations[0].params).toEqual(['1', 'Alice']) + }) + + it('should import raw CSV data with text/csv content type', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: 'id,name\n1,Alice', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + expect(executeOperation).toHaveBeenCalledTimes(1) + }) + + it('should import CSV file uploaded via multipart form data', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const formData = new FormData() + formData.append('file', new File(['id,name\n1,Alice'], 'data.csv')) + + const request = new Request('http://localhost', { + method: 'POST', + body: formData, + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + expect(executeOperation).toHaveBeenCalledTimes(1) + }) + + it('should return 400 when multipart form has no file', async () => { + const formData = new FormData() + formData.append('notFile', 'nope') + + const request = new Request('http://localhost', { + method: 'POST', + body: formData, + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe('No file uploaded') + }) + + it('should return 400 for empty CSV data', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: '' }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + expect((await jsonResponse(response)).error).toBe( + 'Invalid CSV format or empty data' + ) + }) + + it('should skip rows whose column count does not match the header', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'id,name\n1,Alice,extra\n2,Bob', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + expect(executeOperation).toHaveBeenCalledTimes(1) + const [operations] = (executeOperation as any).mock.calls[0] + expect(operations[0].params).toEqual(['2', 'Bob']) + }) + + it('should apply column mapping to headers', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'id,name\n1,Alice', + columnMapping: { name: 'full_name' }, + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const [operations] = (executeOperation as any).mock.calls[0] + expect(operations[0].sql).toBe( + 'INSERT INTO users (id, full_name) VALUES (?, ?)' + ) + expect(operations[0].params).toEqual(['1', 'Alice']) + }) + + it('should report failed statements while importing the remaining records', async () => { + ;(executeOperation as any) + .mockRejectedValueOnce(new Error('UNIQUE constraint failed')) + .mockResolvedValueOnce([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'id,name\n1,Alice\n2,Bob' }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const body = await jsonResponse(response) + expect(body.result.message).toBe( + 'Imported 1 out of 2 records successfully. 1 records failed.' + ) + expect(body.result.failedStatements).toEqual([ + { + statement: 'INSERT INTO users (id, name) VALUES (?, ?)', + error: 'UNIQUE constraint failed', + }, + ]) + }) + + it('should use a generic error message when the failure error has no message', async () => { + ;(executeOperation as any).mockRejectedValue({}) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'id,name\n1,Alice' }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const body = await jsonResponse(response) + expect(body.result.failedStatements[0].error).toBe('Unknown error') + }) + + it('should preserve quoted values containing commas', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'id,name\n1,"Doe, John"\n2,Jane', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const body = await jsonResponse(response) + expect(body.result.message).toBe( + 'Imported 2 out of 2 records successfully. 0 records failed.' + ) + expect((executeOperation as any).mock.calls[0][0][0].params).toEqual([ + '1', + 'Doe, John', + ]) + expect((executeOperation as any).mock.calls[1][0][0].params).toEqual([ + '2', + 'Jane', + ]) + }) + + it('should unescape doubled quotes inside quoted values', async () => { + ;(executeOperation as any).mockResolvedValue([]) + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'id,quote\n1,"He said ""hello"""', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + expect((executeOperation as any).mock.calls[0][0][0].params).toEqual([ + '1', + 'He said "hello"', + ]) + }) + + it('should return 500 when parsing the request fails', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json at all', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(500) + expect((await jsonResponse(response)).error).toContain( + 'Failed to import CSV data' + ) + }) +}) diff --git a/src/import/csv.ts b/src/import/csv.ts index aaf9e86..e527413 100644 --- a/src/import/csv.ts +++ b/src/import/csv.ts @@ -110,13 +110,40 @@ export async function importTableFromCsvRoute( } } +function splitCsvLine(line: string): string[] { + const values: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (char === ',' && !inQuotes) { + values.push(current.trim()) + current = '' + } else { + current += char + } + } + + values.push(current.trim()) + return values +} + function parseCSV(csv: string): Record[] { const lines = csv.split('\n') - const headers = lines[0].split(',').map((header) => header.trim()) + const headers = splitCsvLine(lines[0]) const records: Record[] = [] for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(',').map((value) => value.trim()) + const values = splitCsvLine(lines[i]) if (values.length === headers.length) { const record: Record = {} headers.forEach((header, index) => { diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..46107f7 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import worker, { Env } from './index' +import { StarbaseDB } from './handler' +import { corsPreflight } from './cors' +import { createRemoteJWKSet, jwtVerify } from 'jose' +import { WebSocketPlugin } from '../plugins/websocket' +import { InterfacePlugin } from '../plugins/interface' + +vi.mock('./do', () => ({ + StarbaseDBDurableObject: vi.fn(), +})) + +vi.mock('./utils', () => ({ + createResponse: vi.fn( + (data, message, status) => + new Response(JSON.stringify({ result: data, error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ), +})) + +vi.mock('./handler', () => ({ + StarbaseDB: vi.fn().mockImplementation(() => ({ + handlePreAuth: vi.fn().mockResolvedValue(null), + handle: vi.fn().mockResolvedValue(new Response('handled')), + })), +})) + +vi.mock('./cors', () => ({ + corsPreflight: vi.fn(), +})) + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => ({ keyStore: true })), + jwtVerify: vi.fn(), +})) + +vi.mock('../plugins/websocket', () => ({ + WebSocketPlugin: vi.fn(), +})) + +vi.mock('../plugins/studio', () => ({ + StudioPlugin: vi.fn(), +})) + +vi.mock('../plugins/sql-macros', () => ({ + SqlMacrosPlugin: vi.fn(), +})) + +vi.mock('../plugins/cdc', () => ({ + ChangeDataCapturePlugin: vi.fn().mockImplementation(() => ({ + onEvent: vi.fn(), + })), +})) + +vi.mock('../plugins/query-log', () => ({ + QueryLogPlugin: vi.fn(), +})) + +vi.mock('../plugins/stats', () => ({ + StatsPlugin: vi.fn(), +})) + +vi.mock('../plugins/cron', () => ({ + CronPlugin: vi.fn().mockImplementation(() => ({ + onEvent: vi.fn(), + })), +})) + +vi.mock('../plugins/interface', () => ({ + InterfacePlugin: vi.fn().mockImplementation(() => ({ + matchesRoute: vi.fn().mockReturnValue(false), + })), +})) + +function createStub() { + return { + init: vi.fn().mockResolvedValue({ executeQuery: vi.fn() }), + } +} + +function createEnv(overrides: Partial = {}): Env { + return { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockReturnValue('object-id'), + get: vi.fn().mockImplementation(() => createStub()), + } as any, + REGION: '', + HYPERDRIVE: undefined as any, + ...overrides, + } +} + +const ctx = { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), +} as any + +beforeEach(() => { + vi.clearAllMocks() + ;(corsPreflight as any).mockReturnValue(undefined) + ;(jwtVerify as any).mockReset() + ;(InterfacePlugin as any).mockImplementation(() => ({ + matchesRoute: vi.fn().mockReturnValue(false), + })) +}) + +describe('Worker fetch handler', () => { + it('returns the CORS preflight response for OPTIONS requests', async () => { + const preflight = new Response(null, { status: 204 }) + ;(corsPreflight as any).mockReturnValue(preflight) + + const response = await worker.fetch( + new Request('http://localhost', { method: 'OPTIONS' }), + createEnv(), + ctx + ) + + expect(response).toBe(preflight) + }) + + it('continues past OPTIONS when no preflight response applies', async () => { + const response = await worker.fetch( + new Request('http://localhost', { method: 'OPTIONS' }), + createEnv(), + ctx + ) + + expect(response.status).toBe(401) + }) + + it('returns 401 when no authentication token is present', async () => { + const response = await worker.fetch( + new Request('http://localhost', { method: 'POST' }), + createEnv(), + ctx + ) + + expect(response.status).toBe(401) + expect((await response.json()).error).toBe('Unauthorized request') + }) + + it('authenticates with the admin token and sets the admin role', async () => { + const response = await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer admin-token' }, + }), + createEnv(), + ctx + ) + + expect(response.status).toBe(200) + + const config = (StarbaseDB as any).mock.calls[0][0].config + expect(config.role).toBe('admin') + }) + + it('authenticates with the client token and keeps the client role', async () => { + const response = await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv(), + ctx + ) + + expect(response.status).toBe(200) + const config = (StarbaseDB as any).mock.calls[0][0].config + expect(config.role).toBe('client') + }) + + it('returns 400 when the token matches nothing and no JWKS endpoint is set', async () => { + const response = await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer invalid-token' }, + }), + createEnv(), + ctx + ) + + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Unauthorized request') + }) + + it('accepts a valid JWT with a subject via the JWKS endpoint', async () => { + ;(jwtVerify as any).mockResolvedValue({ payload: { sub: 'user-1' } }) + + const response = await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer jwt-token' }, + }), + createEnv({ + AUTH_JWKS_ENDPOINT: 'https://example.com/jwks', + AUTH_ALGORITHM: 'RS256', + }), + ctx + ) + + expect(response.status).toBe(200) + expect(createRemoteJWKSet).toHaveBeenCalledWith( + new URL('https://example.com/jwks') + ) + expect(jwtVerify).toHaveBeenCalledWith('jwt-token', expect.anything(), { + algorithms: ['RS256'], + }) + }) + + it('rejects a JWT payload without a subject', async () => { + ;(jwtVerify as any).mockResolvedValue({ payload: {} }) + + const response = await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer jwt-token' }, + }), + createEnv({ AUTH_JWKS_ENDPOINT: 'https://example.com/jwks' }), + ctx + ) + + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'Invalid JWT payload, subject not found.' + ) + }) + + it('reads the websocket token from the query parameter', async () => { + const response = await worker.fetch( + new Request('http://localhost?token=client-token&source=external', { + method: 'GET', + headers: { Upgrade: 'websocket' }, + }), + createEnv(), + ctx + ) + + expect(response.status).toBe(200) + const dataSource = (StarbaseDB as any).mock.calls[0][0].dataSource + expect(dataSource.source).toBe('external') + }) + + it('normalizes the source header to external, hyperdrive, or internal', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { + Authorization: 'Bearer client-token', + 'X-Starbase-Source': 'Hyperdrive ', + }, + }), + createEnv(), + ctx + ) + expect((StarbaseDB as any).mock.calls[0][0].dataSource.source).toBe( + 'hyperdrive' + ) + + await worker.fetch( + new Request('http://localhost?source=unknown', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv(), + ctx + ) + expect((StarbaseDB as any).mock.calls[1][0].dataSource.source).toBe( + 'internal' + ) + }) + + it('uses the region location hint when REGION is provided', async () => { + const env = createEnv({ REGION: 'eastus' }) + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + env, + ctx + ) + + expect(env.DATABASE_DURABLE_OBJECT.get).toHaveBeenCalledWith( + 'object-id', + { locationHint: 'eastus' } + ) + }) + + it('returns the pre-auth response when the handler provides one', async () => { + const preAuth = new Response('pre-auth') + ;(StarbaseDB as any).mockImplementation(() => ({ + handlePreAuth: vi.fn().mockResolvedValue(preAuth), + handle: vi.fn(), + })) + + const response = await worker.fetch( + new Request('http://localhost'), + createEnv(), + ctx + ) + + expect(response).toBe(preAuth) + }) + + it('serves interface plugin routes without authentication', async () => { + ;(InterfacePlugin as any).mockImplementation(() => ({ + matchesRoute: vi.fn().mockReturnValue(true), + })) + + const response = await worker.fetch( + new Request('http://localhost/interior'), + createEnv(), + ctx + ) + + expect(response.status).toBe(200) + await response.text() + }) + + it('configures a postgresql external data source', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + EXTERNAL_DB_TYPE: 'postgresql', + EXTERNAL_DB_HOST: 'db.example.com', + EXTERNAL_DB_PORT: 5432, + EXTERNAL_DB_USER: 'user', + EXTERNAL_DB_PASS: 'pass', + EXTERNAL_DB_DATABASE: 'postgres', + EXTERNAL_DB_DEFAULT_SCHEMA: 'public', + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.dialect).toBe('postgresql') + expect(external.host).toBe('db.example.com') + }) + + it('configures a mysql external data source', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + EXTERNAL_DB_TYPE: 'mysql', + EXTERNAL_DB_HOST: 'db.example.com', + EXTERNAL_DB_PORT: 3306, + EXTERNAL_DB_USER: 'user', + EXTERNAL_DB_PASS: 'pass', + EXTERNAL_DB_DATABASE: 'mysql', + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.dialect).toBe('mysql') + }) + + it('configures the cloudflare d1 sqlite provider', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + EXTERNAL_DB_TYPE: 'sqlite', + EXTERNAL_DB_CLOUDFLARE_API_KEY: 'cf-key', + EXTERNAL_DB_CLOUDFLARE_ACCOUNT_ID: 'account', + EXTERNAL_DB_CLOUDFLARE_DATABASE_ID: 'database', + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.provider).toBe('cloudflare-d1') + }) + + it('configures the starbase sqlite provider', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + EXTERNAL_DB_TYPE: 'sqlite', + EXTERNAL_DB_STARBASEDB_URI: 'https://starbase.example.com', + EXTERNAL_DB_STARBASEDB_TOKEN: 'token', + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.provider).toBe('starbase') + }) + + it('configures the turso sqlite provider', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + EXTERNAL_DB_TYPE: 'sqlite', + EXTERNAL_DB_TURSO_URI: 'libsql://example.turso.io', + EXTERNAL_DB_TURSO_TOKEN: 'token', + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.provider).toBe('turso') + }) + + it('uses the hyperdrive connection string when available', async () => { + await worker.fetch( + new Request('http://localhost', { + method: 'POST', + headers: { Authorization: 'Bearer client-token' }, + }), + createEnv({ + HYPERDRIVE: { + connectionString: 'postgres://hyperdrive', + } as any, + }), + ctx + ) + + const external = (StarbaseDB as any).mock.calls[0][0].dataSource + .external + expect(external.connectionString).toBe('postgres://hyperdrive') + }) + + it('returns a 400 error response when an unexpected error occurs', async () => { + const env = createEnv() + ;(env.DATABASE_DURABLE_OBJECT.idFromName as any).mockImplementation( + () => { + throw new Error('binding failure') + } + ) + + const response = await worker.fetch( + new Request('http://localhost'), + env, + ctx + ) + + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('binding failure') + }) + + it('returns a generic error message for non-Error exceptions', async () => { + const env = createEnv() + ;(env.DATABASE_DURABLE_OBJECT.idFromName as any).mockImplementation( + () => { + throw 'boom' + } + ) + + const response = await worker.fetch( + new Request('http://localhost'), + env, + ctx + ) + + expect(response.status).toBe(400) + expect((await response.json()).error).toBe( + 'An unexpected error occurred' + ) + }) +}) diff --git a/src/operation.advanced.test.ts b/src/operation.advanced.test.ts new file mode 100644 index 0000000..de989f9 --- /dev/null +++ b/src/operation.advanced.test.ts @@ -0,0 +1,543 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + executeQuery, + executeTransaction, + executeExternalQuery, + executeSDKQuery, +} from './operation' +import { beforeQueryCache, afterQueryCache } from './cache' +import type { DataSource } from './types' +import type { StarbaseDBConfiguration } from './handler' + +vi.mock('./cache', () => ({ + beforeQueryCache: vi.fn().mockResolvedValue(null), + afterQueryCache: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('./allowlist', () => ({ + isQueryAllowed: vi.fn().mockResolvedValue(true), +})) + +vi.mock('./rls', () => ({ + applyRLS: vi.fn().mockImplementation(({ sql }) => sql), +})) + +vi.mock('pg', () => ({ + Client: vi.fn().mockImplementation(() => ({ connect: vi.fn() })), +})) + +vi.mock('mysql2', () => ({ + createConnection: vi.fn(() => ({ connect: vi.fn() })), +})) + +vi.mock('@libsql/client/web', () => ({ + createClient: vi.fn(() => ({})), +})) + +vi.mock('postgres', () => ({ + default: vi.fn(() => ({ + unsafe: vi.fn().mockResolvedValue([{ id: 1 }]), + end: vi.fn().mockResolvedValue(undefined), + })), +})) + +const mockRaw = vi.fn().mockResolvedValue({ data: [{ id: 1 }] }) + +vi.mock('@outerbase/sdk', () => ({ + CloudflareD1Connection: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + raw: mockRaw, + })), + MySQLConnection: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + raw: mockRaw, + })), + PostgreSQLConnection: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + raw: mockRaw, + })), + StarbaseConnection: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + raw: mockRaw, + })), + TursoConnection: vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + raw: mockRaw, + })), +})) + +let mockDataSource: DataSource +let mockConfig: StarbaseDBConfiguration + +beforeEach(() => { + vi.clearAllMocks() + ;(beforeQueryCache as any).mockResolvedValue(null) + ;(afterQueryCache as any).mockResolvedValue(undefined) + + mockDataSource = { + source: 'internal', + rpc: { executeQuery: vi.fn().mockResolvedValue([{ id: 1 }]) }, + } as any + + mockConfig = { + outerbaseApiKey: undefined, + role: 'admin', + features: { allowlist: false, rls: false }, + } as any +}) + +describe('operation module - advanced behaviors', () => { + it('returns an empty array when no data source is provided', async () => { + const result = await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: undefined as any, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + + it('executes internal queries through the rpc binding', async () => { + const result = await executeQuery({ + sql: 'SELECT * FROM users', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: 'SELECT * FROM users', + params: undefined, + isRaw: false, + }) + }) + + it('returns an empty array when the internal query returns nothing', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue(null) + + const result = await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + + it('returns the cached response when one exists for the query', async () => { + const { beforeQueryCache } = await import('./cache') + ;(beforeQueryCache as any).mockResolvedValue([{ cached: true }]) + + const result = await executeQuery({ + sql: 'SELECT * FROM users', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ cached: true }]) + expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('skips the cache lookup for raw queries', async () => { + const { beforeQueryCache } = await import('./cache') + + await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: true, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(beforeQueryCache).not.toHaveBeenCalled() + }) + + it('stores results in the cache for non-raw queries', async () => { + const { afterQueryCache } = await import('./cache') + + await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(afterQueryCache).toHaveBeenCalled() + }) + + it('transforms raw results for raw internal queries', async () => { + ;(mockDataSource.rpc.executeQuery as any).mockResolvedValue({ + columns: ['id', 'name'], + rows: [[1, 'Alice']], + }) + + const result = (await executeQuery({ + sql: 'SELECT id, name FROM users', + params: undefined, + isRaw: true, + dataSource: mockDataSource, + config: mockConfig, + })) as any + + expect(result.rows).toEqual([[1, 'Alice']]) + }) + + it('applies the registry beforeQuery and afterQuery hooks', async () => { + mockDataSource.registry = { + beforeQuery: vi + .fn() + .mockResolvedValue({ sql: 'SELECT patched', params: [1] }), + afterQuery: vi + .fn() + .mockImplementation(({ result }: any) => [ + ...result, + { extra: true }, + ]), + } as any + + const result = await executeQuery({ + sql: 'SELECT * FROM users', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ sql: 'SELECT patched', params: [1] }) + ) + expect(result).toEqual([{ id: 1 }, { extra: true }]) + }) + + it('continues with the unmodified result when the registry afterQuery hook fails', async () => { + mockDataSource.registry = { + beforeQuery: vi + .fn() + .mockResolvedValue({ + sql: 'SELECT patched', + params: undefined, + }), + afterQuery: vi.fn().mockRejectedValue(new Error('hook blew up')), + } as any + + const result = await executeQuery({ + sql: 'SELECT * FROM users', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + }) + + it('executes hyperdrive queries through a postgres pool', async () => { + mockDataSource.source = 'hyperdrive' + mockDataSource.external = { + dialect: 'postgresql', + connectionString: 'postgres://hyperdrive', + } as any + + const result = await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + }) + + it('ends the postgres pool via waitUntil when an execution context exists', async () => { + mockDataSource.source = 'hyperdrive' + mockDataSource.external = { + dialect: 'postgresql', + connectionString: 'postgres://hyperdrive', + } as any + mockDataSource.executionContext = { waitUntil: vi.fn() } as any + + await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(mockDataSource.executionContext.waitUntil).toHaveBeenCalled() + }) + + it('throws when hyperdrive has no connection string', async () => { + mockDataSource.source = 'hyperdrive' + mockDataSource.external = { dialect: 'postgresql' } as any + + await expect( + executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('Hyperdrive connection string not found') + }) + + it('rethrows hyperdrive query failures', async () => { + const postgres = (await import('postgres')).default as any + ;(postgres as any).mockImplementation(() => ({ + unsafe: vi.fn().mockRejectedValue(new Error('pg down')), + end: vi.fn().mockResolvedValue(undefined), + })) + + mockDataSource.source = 'hyperdrive' + mockDataSource.external = { + dialect: 'postgresql', + connectionString: 'postgres://hyperdrive', + } as any + + await expect( + executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('pg down') + }) + + it('routes external source queries through executeExternalQuery', async () => { + mockDataSource.source = 'external' + mockDataSource.external = { + dialect: 'postgresql', + host: 'db', + port: 5432, + user: 'u', + password: 'p', + database: 'd', + } as any + mockConfig.outerbaseApiKey = 'ob-key' + + const fetchSpy = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + response: { results: { items: [{ row: 'a' }] } }, + }), + }) + vi.stubGlobal('fetch', fetchSpy) + + const result = await executeQuery({ + sql: 'SELECT 1', + params: undefined, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ row: 'a' }]) + vi.unstubAllGlobals() + }) + + it('executeTransaction aggregates results for each query', async () => { + const result = await executeTransaction({ + queries: [{ sql: 'SELECT 1' }, { sql: 'SELECT 2' }], + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toHaveLength(2) + }) + + it('executeTransaction returns an empty array without a data source', async () => { + const result = await executeTransaction({ + queries: [{ sql: 'SELECT 1' }], + isRaw: false, + dataSource: undefined as any, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + + it('executeExternalQuery throws when no external connection exists', async () => { + await expect( + executeExternalQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('External connection not found.') + }) + + it('executeExternalQuery delegates to the SDK when no API key is configured', async () => { + mockDataSource.external = { + dialect: 'postgresql', + host: 'db', + port: 5432, + user: 'u', + password: 'p', + database: 'd', + } as any + + const result = await executeExternalQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + }) + + it('executeExternalQuery converts array params and calls the Outerbase API', async () => { + mockDataSource.external = { dialect: 'postgresql' } as any + mockConfig.outerbaseApiKey = 'ob-key' + + const fetchSpy = vi.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + response: { results: { items: [{ id: 1 }] } }, + }), + }) + vi.stubGlobal('fetch', fetchSpy) + + const result = await executeExternalQuery({ + sql: 'SELECT * FROM users WHERE id = ?', + params: [5], + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('https://app.outerbase.com/api/v1/ezql/raw') + expect(init.headers['X-Source-Token']).toBe('ob-key') + expect(init.body).toContain(':param0') + vi.unstubAllGlobals() + }) + + it('executeExternalQuery returns an empty array for malformed API responses', async () => { + mockDataSource.external = { dialect: 'postgresql' } as any + mockConfig.outerbaseApiKey = 'ob-key' + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ unexpected: true }), + }) + ) + + const result = await executeExternalQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([]) + vi.unstubAllGlobals() + }) + + it('executeSDKQuery returns an empty array when there is no external connection', async () => { + const result = await executeSDKQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + + it.each([ + [ + 'postgresql', + { + dialect: 'postgresql', + host: 'db', + port: 5432, + user: 'u', + password: 'p', + database: 'd', + }, + ], + [ + 'mysql', + { + dialect: 'mysql', + host: 'db', + port: 3306, + user: 'u', + password: 'p', + database: 'd', + }, + ], + [ + 'cloudflare-d1', + { + dialect: 'sqlite', + provider: 'cloudflare-d1', + apiKey: 'key', + accountId: 'acct', + databaseId: 'dbid', + }, + ], + [ + 'starbase', + { + dialect: 'sqlite', + provider: 'starbase', + apiKey: 'https://api', + token: 'token', + }, + ], + [ + 'turso', + { + dialect: 'sqlite', + provider: 'turso', + uri: 'libsql://example', + token: 'token', + }, + ], + ])( + 'executeSDKQuery connects through the %s driver', + async (_, external) => { + mockDataSource.external = external as any + + const result = await executeSDKQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + } + ) + + it('executeSDKQuery throws for unsupported providers', async () => { + mockDataSource.external = { dialect: 'mongodb' } as any + + await expect( + executeSDKQuery({ + sql: 'SELECT 1', + params: [], + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('Unsupported external database type') + }) +})