diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts
new file mode 100644
index 0000000..46150f5
--- /dev/null
+++ b/src/allowlist/index.test.ts
@@ -0,0 +1,149 @@
+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
+let mockConfig: StarbaseDBConfiguration
+
+beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockDataSource = {
+ source: 'internal',
+ rpc: {
+ executeQuery: vi.fn(),
+ },
+ } as any
+
+ mockConfig = {
+ outerbaseApiKey: 'mock-api-key',
+ role: 'client',
+ features: { allowlist: true, rls: true, rest: true },
+ }
+})
+
+describe('Allowlist Module (isQueryAllowed)', () => {
+ it('should return true if allowlist is not enabled', async () => {
+ const result = await isQueryAllowed({
+ sql: 'SELECT * FROM users',
+ isEnabled: false,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toBe(true)
+ expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled()
+ })
+
+ it('should return true if role is admin', async () => {
+ mockConfig.role = 'admin'
+
+ const result = await isQueryAllowed({
+ sql: 'DROP TABLE users',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toBe(true)
+ expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled()
+ })
+
+ it('should return error if sql is empty', async () => {
+ vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([])
+
+ const result = await isQueryAllowed({
+ sql: '',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toBeInstanceOf(Error)
+ expect((result as Error).message).toBe(
+ 'No SQL provided for allowlist check'
+ )
+ })
+
+ it('should allow queries matching the allowlist AST structure', async () => {
+ vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([
+ {
+ sql_statement: 'SELECT id, name FROM users WHERE id = 1;',
+ source: 'internal',
+ },
+ ])
+
+ const result = await isQueryAllowed({
+ sql: 'SELECT id, name FROM users WHERE id = 1',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toBe(true)
+ })
+
+ it('should reject unallowed queries and record rejection', async () => {
+ vi.mocked(mockDataSource.rpc.executeQuery)
+ // loadAllowlist
+ .mockResolvedValueOnce([
+ {
+ sql_statement: 'SELECT * FROM users;',
+ source: 'internal',
+ },
+ ])
+ // addRejectedQuery
+ .mockResolvedValueOnce([{ sql_statement: 'DELETE FROM users;' }])
+
+ await expect(
+ isQueryAllowed({
+ sql: 'DELETE FROM users',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('Query not allowed')
+
+ expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({
+ sql: 'INSERT INTO tmp_allowlist_rejections (sql_statement, source) VALUES (?, ?)',
+ params: ['DELETE FROM users', 'internal'],
+ })
+ })
+
+ it('should handle loadAllowlist error gracefully and reject query', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(mockDataSource.rpc.executeQuery)
+ // loadAllowlist error
+ .mockRejectedValueOnce(new Error('Connection failure'))
+ // addRejectedQuery
+ .mockResolvedValueOnce([])
+
+ await expect(
+ isQueryAllowed({
+ sql: 'SELECT * FROM users',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('Query not allowed')
+ })
+
+ it('should handle addRejectedQuery error gracefully without crashing', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.mocked(mockDataSource.rpc.executeQuery)
+ // loadAllowlist returns empty
+ .mockResolvedValueOnce([])
+ // addRejectedQuery throws
+ .mockRejectedValueOnce(new Error('Failed to insert rejection'))
+
+ await expect(
+ isQueryAllowed({
+ sql: 'SELECT secret FROM confidential',
+ isEnabled: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('Query not allowed')
+ })
+})
diff --git a/src/do.test.ts b/src/do.test.ts
index 272c4e9..74d7947 100644
--- a/src/do.test.ts
+++ b/src/do.test.ts
@@ -3,7 +3,14 @@ import { StarbaseDBDurableObject } from './do'
vi.mock('cloudflare:workers', () => {
return {
- DurableObject: class MockDurableObject {},
+ DurableObject: class MockDurableObject {
+ ctx: any
+ env: any
+ constructor(ctx: any, env: any) {
+ this.ctx = ctx
+ this.env = env
+ }
+ },
}
})
@@ -37,7 +44,9 @@ global.WebSocket = class {
send = vi.fn()
close = vi.fn()
accept = vi.fn()
- addEventListener = vi.fn()
+ addEventListener = vi.fn((event: string, handler: Function) => {
+ ;(this as any)[`on_${event}`] = handler
+ })
}
global.WebSocketPair = vi.fn(() => {
@@ -60,6 +69,7 @@ global.Response = class {
const mockStorage = {
sql: {
+ databaseSize: 1024,
exec: vi.fn().mockReturnValue({
columnNames: ['id', 'name'],
raw: vi.fn().mockReturnValue([
@@ -74,6 +84,9 @@ const mockStorage = {
rowsWritten: 1,
}),
},
+ getAlarm: vi.fn().mockResolvedValue(123456789),
+ setAlarm: vi.fn().mockResolvedValue(undefined),
+ deleteAlarm: vi.fn().mockResolvedValue(undefined),
}
const mockDurableObjectState = {
@@ -81,11 +94,26 @@ const mockDurableObjectState = {
getTags: vi.fn().mockReturnValue(['session-123']),
} as any
-const mockEnv = {} as any
+const mockEnv = {
+ CLIENT_AUTHORIZATION_TOKEN: 'mock-client-token',
+} as any
let instance: StarbaseDBDurableObject
beforeEach(() => {
+ mockStorage.sql.exec.mockReturnValue({
+ columnNames: ['id', 'name'],
+ raw: vi.fn().mockReturnValue([
+ [1, 'Alice'],
+ [2, 'Bob'],
+ ]),
+ toArray: vi.fn().mockReturnValue([
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bob' },
+ ]),
+ rowsRead: 2,
+ rowsWritten: 1,
+ })
instance = new StarbaseDBDurableObject(mockDurableObjectState, mockEnv)
vi.clearAllMocks()
})
@@ -96,6 +124,15 @@ describe('StarbaseDBDurableObject Tests', () => {
expect(instance.storage).toBeDefined()
})
+ it('should expose init methods', () => {
+ const methods = instance.init()
+ expect(typeof methods.getAlarm).toBe('function')
+ expect(typeof methods.setAlarm).toBe('function')
+ expect(typeof methods.deleteAlarm).toBe('function')
+ expect(typeof methods.getStatistics).toBe('function')
+ expect(typeof methods.executeQuery).toBe('function')
+ })
+
it('should execute a query and return results', async () => {
const sql = 'SELECT * FROM users'
const result = await instance.executeQuery({ sql })
@@ -107,6 +144,28 @@ describe('StarbaseDBDurableObject Tests', () => {
])
})
+ it('should execute a raw query and return formatted columns and rows', async () => {
+ const sql = 'SELECT * FROM users'
+ const result = await instance.executeQuery({
+ sql,
+ params: [1, 'test'],
+ isRaw: true,
+ })
+
+ expect(mockStorage.sql.exec).toHaveBeenCalledWith(sql, 1, 'test')
+ expect(result).toEqual({
+ columns: ['id', 'name'],
+ rows: [
+ [1, 'Alice'],
+ [2, 'Bob'],
+ ],
+ meta: {
+ rows_read: 2,
+ rows_written: 1,
+ },
+ })
+ })
+
it('should execute a transaction and return results', async () => {
const queries = [
{ sql: 'SELECT * FROM orders' },
@@ -118,6 +177,103 @@ describe('StarbaseDBDurableObject Tests', () => {
expect(result.length).toBe(2)
})
+ it('should handle errors in executeTransaction', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockStorage.sql.exec.mockImplementationOnce(() => {
+ throw new Error('Transaction query failed')
+ })
+
+ await expect(
+ instance.executeTransaction([{ sql: 'BAD SQL' }], false)
+ ).rejects.toThrow('Transaction query failed')
+ })
+
+ it('should get and set alarms correctly', async () => {
+ const alarmTime = await instance.getAlarm()
+ expect(alarmTime).toBe(123456789)
+
+ await instance.setAlarm(Date.now() + 5000)
+ expect(mockStorage.setAlarm).toHaveBeenCalled()
+
+ await instance.setAlarm(new Date(Date.now() + 10000))
+ expect(mockStorage.setAlarm).toHaveBeenCalled()
+
+ await instance.deleteAlarm()
+ expect(mockStorage.deleteAlarm).toHaveBeenCalled()
+ })
+
+ it('should handle errors when setting alarm', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockStorage.setAlarm.mockRejectedValueOnce(new Error('Alarm failure'))
+
+ await expect(instance.setAlarm(Date.now() + 5000)).rejects.toThrow(
+ 'Alarm failure'
+ )
+ })
+
+ it('should process alarm when tasks exist', async () => {
+ const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
+ ok: true,
+ } as Response)
+
+ mockStorage.sql.exec.mockReturnValueOnce({
+ columnNames: ['callback_host', 'is_active'],
+ toArray: () => [
+ { callback_host: 'https://callback.com', is_active: 1 },
+ ],
+ })
+
+ await instance.alarm()
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://callback.com/cron/callback',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({
+ Authorization: 'Bearer mock-client-token',
+ }),
+ })
+ )
+ })
+
+ it('should handle alarm callback failure and reschedule', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(global, 'fetch').mockRejectedValueOnce(
+ new Error('Callback failed')
+ )
+
+ mockStorage.sql.exec.mockReturnValueOnce({
+ columnNames: ['callback_host', 'is_active'],
+ toArray: () => [
+ { callback_host: 'https://callback.com', is_active: 1 },
+ ],
+ })
+
+ await instance.alarm()
+ expect(mockStorage.setAlarm).toHaveBeenCalled()
+ })
+
+ it('should return early from alarm when no tasks exist', async () => {
+ const fetchMock = vi.spyOn(global, 'fetch')
+ mockStorage.sql.exec.mockReturnValueOnce({
+ columnNames: ['callback_host'],
+ toArray: () => [],
+ })
+
+ await instance.alarm()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('should get statistics', async () => {
+ mockStorage.sql.exec.mockReturnValueOnce({
+ toArray: () => [{ count: 42 }],
+ })
+
+ const stats = await instance.getStatistics()
+ expect(stats.databaseSize).toBe(1024)
+ expect(stats.recentQueries).toBe(42)
+ })
+
it('should handle WebSocket connections', async () => {
const response = await instance.clientConnected('session-123')
@@ -125,6 +281,81 @@ describe('StarbaseDBDurableObject Tests', () => {
expect(instance.connections.has('session-123')).toBe(true)
})
+ it('should handle WebSocket without explicit sessionId', async () => {
+ const response = await instance.clientConnected()
+ expect(response.status).toBe(101)
+ expect(instance.connections.size).toBeGreaterThan(0)
+ })
+
+ it('should route /socket and /socket/broadcast in fetch', async () => {
+ const wsReq = new Request('https://example.com/socket', {
+ headers: { upgrade: 'websocket' },
+ })
+ const wsRes = await instance.fetch(wsReq)
+ expect(wsRes.status).toBe(101)
+
+ const badWsReq = new Request('https://example.com/socket')
+ const badWsRes = await instance.fetch(badWsReq)
+ expect(badWsRes.status).toBe(400)
+
+ // Broadcast
+ await instance.clientConnected('session-target')
+ const broadcastReq = new Request(
+ 'https://example.com/socket/broadcast?sessionId=session-target',
+ {
+ method: 'POST',
+ body: JSON.stringify({ type: 'EVENT', data: 123 }),
+ }
+ )
+ const broadcastRes = await instance.fetch(broadcastReq)
+ expect(broadcastRes.status).toBe(200)
+ })
+
+ it('should clean up dead connection on broadcast error', async () => {
+ const mockWs = {
+ send: vi.fn(() => {
+ throw new Error('Dead connection')
+ }),
+ } as any
+ instance.connections.set('dead-session', mockWs)
+
+ const broadcastReq = new Request(
+ 'https://example.com/socket/broadcast',
+ {
+ method: 'POST',
+ body: JSON.stringify({ type: 'ALL' }),
+ }
+ )
+ await instance.fetch(broadcastReq)
+ expect(instance.connections.has('dead-session')).toBe(false)
+ })
+
+ it('should handle webSocketMessage query action', async () => {
+ const mockWs = { send: vi.fn() } as any
+ await instance.webSocketMessage(
+ mockWs,
+ JSON.stringify({
+ action: 'query',
+ sql: 'SELECT 1',
+ params: [],
+ })
+ )
+ expect(mockWs.send).toHaveBeenCalled()
+ })
+
+ it('should handle webSocketClose and remove connection by tag', async () => {
+ const mockWs = { close: vi.fn() } as any
+ instance.connections.set('session-123', mockWs)
+
+ await instance.webSocketClose(mockWs, 1000, 'Normal closure', true)
+
+ expect(mockWs.close).toHaveBeenCalledWith(
+ 1000,
+ 'StarbaseDB is closing WebSocket connection'
+ )
+ expect(instance.connections.has('session-123')).toBe(false)
+ })
+
it('should return 400 for unknown fetch requests', async () => {
const request = new Request('https://example.com/unknown')
const response = await instance.fetch(request)
@@ -133,9 +364,7 @@ describe('StarbaseDBDurableObject Tests', () => {
})
it('should handle errors in executeQuery', async () => {
- const consoleErrorSpy = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {}) // ✅ Suppress error logs
+ vi.spyOn(console, 'error').mockImplementation(() => {})
mockStorage.sql.exec.mockImplementationOnce(() => {
throw new Error('Query failed')
diff --git a/src/handler.test.ts b/src/handler.test.ts
index 86bb328..16cf9c9 100644
--- a/src/handler.test.ts
+++ b/src/handler.test.ts
@@ -199,4 +199,93 @@ describe('StarbaseDB Error Handling', () => {
expect(response.status).toBe(500)
})
+
+ it('should return 400 if Content-Type is not application/json', async () => {
+ const request = new Request('https://example.com/query', {
+ method: 'POST',
+ body: 'SELECT 1',
+ headers: { 'Content-Type': 'text/plain' },
+ })
+
+ const response = await instance.queryRoute(request, false)
+ expect(response.status).toBe(400)
+ })
+
+ it('should return 400 if params is neither an array nor an object', async () => {
+ const request = new Request('https://example.com/query', {
+ method: 'POST',
+ body: JSON.stringify({ sql: 'SELECT 1', params: 12345 }),
+ headers: { 'Content-Type': 'application/json' },
+ })
+
+ const response = await instance.queryRoute(request, false)
+ expect(response.status).toBe(400)
+ })
+
+ it('should handle transaction with invalid sql or params', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const badSqlRequest = new Request('https://example.com/query', {
+ method: 'POST',
+ body: JSON.stringify({ transaction: [{ sql: '' }] }),
+ headers: { 'Content-Type': 'application/json' },
+ })
+ const res1 = await instance.queryRoute(badSqlRequest, false)
+ expect(res1.status).toBe(500)
+
+ const badParamsRequest = new Request('https://example.com/query', {
+ method: 'POST',
+ body: JSON.stringify({
+ transaction: [{ sql: 'SELECT 1', params: 12345 }],
+ }),
+ headers: { 'Content-Type': 'application/json' },
+ })
+ const res2 = await instance.queryRoute(badParamsRequest, false)
+ expect(res2.status).toBe(500)
+ })
+
+ it('should throw error in constructor if external source is missing external config', () => {
+ expect(() => {
+ new StarbaseDB({
+ dataSource: { source: 'external' } as any,
+ config: mockConfig,
+ })
+ }).toThrow('No external data sources available.')
+ })
+
+ it('should handle pre-auth plugins matching request url', async () => {
+ const customPlugin = {
+ opts: { requiresAuth: false },
+ pathPrefix: '/public/*',
+ } as any
+
+ const customInstance = new StarbaseDB({
+ dataSource: mockDataSource,
+ config: mockConfig,
+ plugins: [customPlugin],
+ })
+
+ const req = new Request('https://example.com/public/data')
+ const res = await customInstance.handlePreAuth(
+ req,
+ mockExecutionContext
+ )
+ expect(res).toBeDefined()
+
+ const unauthReq = new Request('https://example.com/private/data')
+ const unauthRes = await customInstance.handlePreAuth(
+ unauthReq,
+ mockExecutionContext
+ )
+ expect(unauthRes).toBeUndefined()
+ })
+
+ it('should handle error gracefully in expireCache', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockDataSource.rpc.executeQuery = vi.fn().mockImplementation(() => {
+ throw new Error('Cache cleanup error')
+ })
+
+ await expect(instance['expireCache']()).resolves.not.toThrow()
+ })
})
diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts
new file mode 100644
index 0000000..ec8b455
--- /dev/null
+++ b/src/import/csv.test.ts
@@ -0,0 +1,298 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { importTableFromCsvRoute } from './csv'
+import { executeOperation } from '../export'
+import { createResponse } from '../utils'
+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()
+
+ mockDataSource = {
+ source: 'internal',
+ rpc: { executeQuery: vi.fn() },
+ } as any
+
+ mockConfig = {
+ outerbaseApiKey: 'mock-api-key',
+ role: 'admin',
+ features: { allowlist: true, rls: true, rest: true },
+ }
+})
+
+describe('CSV Import Module (importTableFromCsvRoute)', () => {
+ it('should return 400 if request body is empty', async () => {
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ }
+ )
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(400)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('Request body is empty')
+ })
+
+ it('should return 400 for unsupported content-type', async () => {
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/xml' },
+ body: '',
+ }
+ )
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(400)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('Unsupported Content-Type')
+ })
+
+ it('should import raw text/csv successfully', async () => {
+ const csvContent = 'id,name\n1,Alice\n2,Bob'
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/csv' },
+ body: csvContent,
+ }
+ )
+
+ vi.mocked(executeOperation).mockResolvedValue([])
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as any
+ expect(body.result.message).toContain(
+ 'Imported 2 out of 2 records successfully'
+ )
+ expect(executeOperation).toHaveBeenCalledTimes(2)
+ expect(executeOperation).toHaveBeenCalledWith(
+ [
+ {
+ sql: 'INSERT INTO users (id, name) VALUES (?, ?)',
+ params: ['1', 'Alice'],
+ },
+ ],
+ mockDataSource,
+ mockConfig
+ )
+ })
+
+ it('should import application/json wrapped CSV with column mapping', async () => {
+ const payload = {
+ data: 'emp_id,full_name\n101,John Doe',
+ columnMapping: {
+ emp_id: 'id',
+ full_name: 'name',
+ },
+ }
+
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ }
+ )
+
+ vi.mocked(executeOperation).mockResolvedValue([])
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as any
+ expect(body.result.message).toContain(
+ 'Imported 1 out of 1 records successfully'
+ )
+ expect(executeOperation).toHaveBeenCalledWith(
+ [
+ {
+ sql: 'INSERT INTO users (id, name) VALUES (?, ?)',
+ params: ['101', 'John Doe'],
+ },
+ ],
+ mockDataSource,
+ mockConfig
+ )
+ })
+
+ it('should import multipart/form-data CSV file', async () => {
+ const formData = new FormData()
+ const file = new File(['id,name\n1,Charlie'], 'test.csv', {
+ type: 'text/csv',
+ })
+ formData.append('file', file)
+
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ body: formData,
+ }
+ )
+
+ vi.mocked(executeOperation).mockResolvedValue([])
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as any
+ expect(body.result.message).toContain(
+ 'Imported 1 out of 1 records successfully'
+ )
+ })
+
+ it('should return 400 when multipart/form-data lacks a file', async () => {
+ const formData = new FormData()
+ formData.append('other_field', 'data')
+
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ body: formData,
+ }
+ )
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(400)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('No file uploaded')
+ })
+
+ it('should return 400 when CSV has no data rows', async () => {
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/csv' },
+ body: 'id,name\n',
+ }
+ )
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(400)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('Invalid CSV format or empty data')
+ })
+
+ it('should report failed statements when executeOperation throws for a row', async () => {
+ const csvContent = 'id,name\n1,Alice\n2,Bob'
+ const request = new Request(
+ 'https://api.starbasedb.com/import/csv/users',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/csv' },
+ body: csvContent,
+ }
+ )
+
+ vi.mocked(executeOperation)
+ .mockResolvedValueOnce([])
+ .mockRejectedValueOnce(
+ new Error('UNIQUE constraint failed: users.id')
+ )
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as any
+ expect(body.result.message).toBe(
+ 'Imported 1 out of 2 records successfully. 1 records failed.'
+ )
+ expect(body.result.failedStatements.length).toBe(1)
+ expect(body.result.failedStatements[0].error).toBe(
+ 'UNIQUE constraint failed: users.id'
+ )
+ })
+
+ it('should return 500 when request body parsing throws an unhandled error', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const request = {
+ body: true,
+ headers: {
+ get: () => 'application/json',
+ },
+ json: () => Promise.reject(new Error('Malformed JSON payload')),
+ } as any
+
+ const response = await importTableFromCsvRoute(
+ 'users',
+ request,
+ mockDataSource,
+ mockConfig
+ )
+
+ expect(response.status).toBe(500)
+ const body = (await response.json()) as any
+ expect(body.error).toContain('Failed to import CSV data')
+ })
+})
diff --git a/src/index.test.ts b/src/index.test.ts
new file mode 100644
index 0000000..18ae025
--- /dev/null
+++ b/src/index.test.ts
@@ -0,0 +1,288 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import worker, { type Env } from './index'
+import { corsPreflight } from './cors'
+import { StarbaseDB } from './handler'
+
+vi.mock('cloudflare:workers', () => ({
+ DurableObject: class MockDurableObject {},
+}))
+
+vi.mock('./cors', () => ({
+ corsPreflight: vi.fn(),
+ corsHeaders: {
+ 'Access-Control-Allow-Origin': '*',
+ },
+}))
+
+vi.mock('./handler', () => {
+ return {
+ StarbaseDB: vi.fn().mockImplementation(() => ({
+ handlePreAuth: vi.fn().mockResolvedValue(null),
+ handle: vi
+ .fn()
+ .mockResolvedValue(new Response('ok', { status: 200 })),
+ })),
+ StarbaseDBConfiguration: {},
+ }
+})
+
+let mockEnv: Env
+let mockCtx: ExecutionContext
+let mockStub: any
+
+beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockStub = {
+ init: vi.fn().mockResolvedValue({
+ executeQuery: vi.fn().mockResolvedValue([]),
+ }),
+ }
+
+ mockEnv = {
+ ADMIN_AUTHORIZATION_TOKEN: 'admin-token-123',
+ CLIENT_AUTHORIZATION_TOKEN: 'client-token-456',
+ REGION: 'auto',
+ DATABASE_DURABLE_OBJECT: {
+ idFromName: vi.fn().mockReturnValue('mock-id'),
+ get: vi.fn().mockReturnValue(mockStub),
+ } as any,
+ }
+
+ mockCtx = {
+ waitUntil: vi.fn(),
+ passThroughOnException: vi.fn(),
+ } as any
+})
+
+describe('Worker Fetch Handler (src/index.ts)', () => {
+ it('should return CORS preflight response for OPTIONS requests', async () => {
+ vi.mocked(corsPreflight).mockReturnValueOnce(
+ new Response(null, { status: 204 })
+ )
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ method: 'OPTIONS',
+ })
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(204)
+ expect(corsPreflight).toHaveBeenCalled()
+ })
+
+ it('should return 401 if authorization token is missing', async () => {
+ const request = new Request('https://api.starbasedb.com/query', {
+ method: 'POST',
+ })
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(401)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('Unauthorized request')
+ })
+
+ it('should authenticate successfully with ADMIN_AUTHORIZATION_TOKEN', async () => {
+ const request = new Request('https://api.starbasedb.com/query', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ },
+ })
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(200)
+ })
+
+ it('should authenticate successfully with CLIENT_AUTHORIZATION_TOKEN', async () => {
+ const request = new Request('https://api.starbasedb.com/query', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer client-token-456',
+ },
+ })
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(200)
+ })
+
+ it('should return 400 if token is invalid and no JWKS endpoint is set', async () => {
+ const request = new Request('https://api.starbasedb.com/query', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer invalid-token-xyz',
+ },
+ })
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(400)
+ const body = (await response.json()) as any
+ expect(body.error).toBe('Unauthorized request')
+ })
+
+ it('should authenticate WebSocket requests using query param token', async () => {
+ const request = new Request(
+ 'https://api.starbasedb.com/websocket?token=admin-token-123',
+ {
+ headers: {
+ Upgrade: 'websocket',
+ },
+ }
+ )
+
+ const response = await worker.fetch(request, mockEnv, mockCtx)
+ expect(response.status).toBe(200)
+ })
+
+ it('should apply region locationHint when REGION is not auto', async () => {
+ mockEnv.REGION = 'wnam'
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: { Authorization: 'Bearer admin-token-123' },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(mockEnv.DATABASE_DURABLE_OBJECT.get).toHaveBeenCalledWith(
+ 'mock-id',
+ { locationHint: 'wnam' }
+ )
+ })
+
+ it('should configure postgresql external database dataSource', async () => {
+ mockEnv.EXTERNAL_DB_TYPE = 'postgresql'
+ mockEnv.EXTERNAL_DB_HOST = 'pg.example.com'
+ mockEnv.EXTERNAL_DB_PORT = 5432
+ mockEnv.EXTERNAL_DB_USER = 'user'
+ mockEnv.EXTERNAL_DB_PASS = 'pass'
+ mockEnv.EXTERNAL_DB_DATABASE = 'db'
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ 'X-Starbase-Source': 'external',
+ },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(StarbaseDB).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataSource: expect.objectContaining({
+ source: 'external',
+ external: expect.objectContaining({
+ dialect: 'postgresql',
+ host: 'pg.example.com',
+ }),
+ }),
+ })
+ )
+ })
+
+ it('should configure mysql external database dataSource', async () => {
+ mockEnv.EXTERNAL_DB_TYPE = 'mysql'
+ mockEnv.EXTERNAL_DB_HOST = 'mysql.example.com'
+ mockEnv.EXTERNAL_DB_PORT = 3306
+ mockEnv.EXTERNAL_DB_USER = 'root'
+ mockEnv.EXTERNAL_DB_PASS = 'secret'
+ mockEnv.EXTERNAL_DB_DATABASE = 'testdb'
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ 'X-Starbase-Source': 'external',
+ },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(StarbaseDB).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataSource: expect.objectContaining({
+ source: 'external',
+ external: expect.objectContaining({
+ dialect: 'mysql',
+ host: 'mysql.example.com',
+ }),
+ }),
+ })
+ )
+ })
+
+ it('should configure cloudflare d1 external sqlite dataSource', async () => {
+ mockEnv.EXTERNAL_DB_TYPE = 'sqlite'
+ mockEnv.EXTERNAL_DB_CLOUDFLARE_API_KEY = 'cf-api-key'
+ mockEnv.EXTERNAL_DB_CLOUDFLARE_ACCOUNT_ID = 'cf-acc-1'
+ mockEnv.EXTERNAL_DB_CLOUDFLARE_DATABASE_ID = 'cf-db-1'
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ 'X-Starbase-Source': 'external',
+ },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(StarbaseDB).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataSource: expect.objectContaining({
+ source: 'external',
+ external: expect.objectContaining({
+ dialect: 'sqlite',
+ provider: 'cloudflare-d1',
+ apiKey: 'cf-api-key',
+ }),
+ }),
+ })
+ )
+ })
+
+ it('should configure turso external sqlite dataSource', async () => {
+ mockEnv.EXTERNAL_DB_TYPE = 'sqlite'
+ mockEnv.EXTERNAL_DB_TURSO_URI = 'libsql://test.turso.io'
+ mockEnv.EXTERNAL_DB_TURSO_TOKEN = 'turso-jwt'
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ 'X-Starbase-Source': 'external',
+ },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(StarbaseDB).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataSource: expect.objectContaining({
+ source: 'external',
+ external: expect.objectContaining({
+ dialect: 'sqlite',
+ provider: 'turso',
+ uri: 'libsql://test.turso.io',
+ }),
+ }),
+ })
+ )
+ })
+
+ it('should configure hyperdrive external dataSource', async () => {
+ mockEnv.HYPERDRIVE = {
+ connectionString: 'postgres://hd.example.com/db',
+ } as any
+
+ const request = new Request('https://api.starbasedb.com/query', {
+ headers: {
+ Authorization: 'Bearer admin-token-123',
+ 'X-Starbase-Source': 'hyperdrive',
+ },
+ })
+
+ await worker.fetch(request, mockEnv, mockCtx)
+ expect(StarbaseDB).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataSource: expect.objectContaining({
+ source: 'hyperdrive',
+ external: expect.objectContaining({
+ dialect: 'postgresql',
+ connectionString: 'postgres://hd.example.com/db',
+ }),
+ }),
+ })
+ )
+ })
+})
diff --git a/src/mocks/cloudflare-workers.ts b/src/mocks/cloudflare-workers.ts
new file mode 100644
index 0000000..e325a6f
--- /dev/null
+++ b/src/mocks/cloudflare-workers.ts
@@ -0,0 +1,2 @@
+export class DurableObject {}
+export class WorkerEntrypoint {}
diff --git a/src/operation.test.ts b/src/operation.test.ts
index f52cbb9..6d98ec7 100644
--- a/src/operation.test.ts
+++ b/src/operation.test.ts
@@ -12,80 +12,45 @@ import type { DataSource } from './types'
import type { StarbaseDBConfiguration } from './handler'
import type { SqlConnection } from '@outerbase/sdk/dist/connections/sql-base'
-// const mockSqlConnection = vi.hoisted(() => ({
-// connect: vi.fn().mockResolvedValue(undefined),
-// raw: vi
-// .fn()
-// .mockResolvedValue({ data: [{ id: 1, name: 'SDK-Test-Result' }] }),
-// })) as unknown as SqlConnection
-
-// const mockConfig = vi.hoisted(() => ({
-// outerbaseApiKey: 'mock-api-key',
-// role: 'admin',
-// features: { allowlist: true, rls: true, rest: true },
-// })) as StarbaseDBConfiguration
-
-// const mockDataSource = vi.hoisted(() => ({
-// source: 'internal',
-// external: {
-// dialect: 'postgresql',
-// provider: 'postgresql',
-// host: 'mock-host',
-// port: 5432,
-// user: 'mock-user',
-// password: 'mock-password',
-// database: 'mock-db',
-// } as any,
-// rpc: {
-// executeQuery: vi.fn().mockResolvedValue([
-// { id: 1, name: 'Alice' },
-// { id: 2, name: 'Bob' },
-// ]),
-// },
-// })) as unknown as DataSource
+const mockSqlConnection = vi.hoisted(() => ({
+ connect: vi.fn().mockResolvedValue(undefined),
+ raw: vi
+ .fn()
+ .mockResolvedValue({ data: [{ id: 1, name: 'SDK-Test-Result' }] }),
+})) as unknown as SqlConnection
+
+vi.mock('pg', () => ({
+ Client: vi.fn().mockImplementation(() => ({})),
+}))
-// vi.mock('./operation', async (importOriginal) => {
-// const actual = await importOriginal()
-// return {
-// ...actual,
-// executeQuery: vi.fn().mockResolvedValue([
-// { id: 1, name: 'Mocked Alice' },
-// { id: 2, name: 'Mocked Bob' },
-// ]),
-// executeSDKQuery: vi
-// .fn()
-// .mockResolvedValue([{ id: 1, name: 'SDK-Result' }]),
-// createSDKPostgresConnection: vi
-// .fn()
-// .mockResolvedValue({ database: mockSqlConnection }),
-// createSDKMySQLConnection: vi
-// .fn()
-// .mockResolvedValue({ database: mockSqlConnection }),
-// createSDKCloudflareConnection: vi
-// .fn()
-// .mockResolvedValue({ database: mockSqlConnection }),
-// createSDKStarbaseConnection: vi
-// .fn()
-// .mockResolvedValue({ database: mockSqlConnection }),
-// createSDKTursoConnection: vi
-// .fn()
-// .mockResolvedValue({ database: mockSqlConnection }),
-// }
-// })
+vi.mock('mysql2', () => ({
+ createConnection: vi.fn(),
+}))
-// vi.mock('./operation', () => ({
-// executeSDKQuery: vi.fn().mockResolvedValue([{ id: 1, name: 'SDK-Result' }]),
-// }))
+vi.mock('@libsql/client/web', () => ({
+ createClient: vi.fn(),
+}))
-// vi.mock('./operation', async (importOriginal) => {
-// const original = await importOriginal()
-// return {
-// ...original,
-// executeSDKQuery: vi
-// .fn()
-// .mockResolvedValue([{ id: 1, name: 'SDK-Result' }]),
-// }
-// })
+vi.mock('postgres', () => {
+ const mockSql = vi
+ .fn()
+ .mockImplementation(() =>
+ Promise.resolve([{ id: 1, name: 'Hyperdrive' }])
+ )
+ ;(mockSql as any).unsafe = vi
+ .fn()
+ .mockResolvedValue([{ id: 1, name: 'Hyperdrive' }])
+ ;(mockSql as any).end = vi.fn().mockResolvedValue(undefined)
+ return { default: vi.fn().mockReturnValue(mockSql) }
+})
+
+vi.mock('@outerbase/sdk', () => ({
+ PostgreSQLConnection: vi.fn().mockImplementation(() => mockSqlConnection),
+ MySQLConnection: vi.fn().mockImplementation(() => mockSqlConnection),
+ CloudflareD1Connection: vi.fn().mockImplementation(() => mockSqlConnection),
+ StarbaseConnection: vi.fn().mockImplementation(() => mockSqlConnection),
+ TursoConnection: vi.fn().mockImplementation(() => mockSqlConnection),
+}))
vi.mock('./allowlist', () => ({ isQueryAllowed: vi.fn() }))
vi.mock('./rls', () => ({ applyRLS: vi.fn(async ({ sql }) => sql) }))
@@ -94,7 +59,6 @@ vi.mock('./cache', () => ({
afterQueryCache: vi.fn(),
}))
-let mockSqlConnection: SqlConnection
let mockDataSource: DataSource
let mockConfig: StarbaseDBConfiguration
@@ -447,141 +411,244 @@ describe('executeExternalQuery', () => {
})
})
-// describe('executeSDKQuery', () => {
-// it('should execute a query using PostgreSQL connection', async () => {
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(mockSqlConnection.connect).toHaveBeenCalled()
-// expect(mockSqlConnection.raw).toHaveBeenCalledWith(
-// 'SELECT * FROM users',
-// []
-// )
-// expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
-// })
-
-// it('should execute a query using MySQL connection', async () => {
-// if (mockDataSource.external) {
-// mockDataSource.external.dialect = 'mysql'
-// }
-
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(mockSqlConnection.connect).toHaveBeenCalled()
-// expect(mockSqlConnection.raw).toHaveBeenCalledWith(
-// 'SELECT * FROM users',
-// []
-// )
-// expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
-// })
-
-// it('should execute a query using Cloudflare D1 connection', async () => {
-// if (mockDataSource.external && 'provider' in mockDataSource.external) {
-// mockDataSource.external.provider = 'cloudflare-d1'
-// }
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(mockSqlConnection.connect).toHaveBeenCalled()
-// expect(mockSqlConnection.raw).toHaveBeenCalledWith(
-// 'SELECT * FROM users',
-// []
-// )
-// expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
-// })
-
-// it('should execute a query using Starbase connection', async () => {
-// if (mockDataSource.external && 'provider' in mockDataSource.external) {
-// mockDataSource.external.provider = 'starbase'
-// }
-
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(mockSqlConnection.connect).toHaveBeenCalled()
-// expect(mockSqlConnection.raw).toHaveBeenCalledWith(
-// 'SELECT * FROM users',
-// []
-// )
-// expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
-// })
-
-// it('should execute a query using Turso connection', async () => {
-// if (mockDataSource.external && 'provider' in mockDataSource.external) {
-// mockDataSource.external.provider = 'turso'
-// }
-
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(mockSqlConnection.connect).toHaveBeenCalled()
-// expect(mockSqlConnection.raw).toHaveBeenCalledWith(
-// 'SELECT * FROM users',
-// []
-// )
-// expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
-// })
-// it('should return an empty array if external connection is missing', async () => {
-// mockDataSource.external = undefined as any
-
-// const result = await executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-
-// expect(result).toEqual([])
-// })
-
-// it('should handle database connection errors gracefully', async () => {
-// vi.mocked(mockSqlConnection.connect).mockRejectedValueOnce(
-// new Error('DB connection failed')
-// )
-
-// await expect(
-// executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-// ).rejects.toThrow('DB connection failed')
-// })
-
-// it('should handle query execution errors gracefully', async () => {
-// vi.mocked(mockSqlConnection.raw).mockRejectedValueOnce(
-// new Error('Query execution failed')
-// )
-
-// await expect(
-// executeSDKQuery({
-// sql: 'SELECT * FROM users',
-// params: [],
-// dataSource: mockDataSource,
-// config: mockConfig,
-// })
-// ).rejects.toThrow('Query execution failed')
-// })
-// })
+describe('executeSDKQuery', () => {
+ it('should execute a query using PostgreSQL connection', async () => {
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(mockSqlConnection.connect).toHaveBeenCalled()
+ expect(mockSqlConnection.raw).toHaveBeenCalledWith(
+ 'SELECT * FROM users',
+ []
+ )
+ expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
+ })
+
+ it('should execute a query using MySQL connection', async () => {
+ if (mockDataSource.external) {
+ mockDataSource.external.dialect = 'mysql'
+ }
+
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(mockSqlConnection.connect).toHaveBeenCalled()
+ expect(mockSqlConnection.raw).toHaveBeenCalledWith(
+ 'SELECT * FROM users',
+ []
+ )
+ expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
+ })
+
+ it('should execute a query using Cloudflare D1 connection', async () => {
+ if (mockDataSource.external && 'provider' in mockDataSource.external) {
+ ;(mockDataSource.external as any).dialect = undefined
+ mockDataSource.external.provider = 'cloudflare-d1'
+ }
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(mockSqlConnection.connect).toHaveBeenCalled()
+ expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
+ })
+
+ it('should execute a query using Starbase connection', async () => {
+ if (mockDataSource.external && 'provider' in mockDataSource.external) {
+ ;(mockDataSource.external as any).dialect = undefined
+ mockDataSource.external.provider = 'starbase'
+ }
+
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(mockSqlConnection.connect).toHaveBeenCalled()
+ expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
+ })
+
+ it('should execute a query using Turso connection', async () => {
+ if (mockDataSource.external && 'provider' in mockDataSource.external) {
+ ;(mockDataSource.external as any).dialect = undefined
+ mockDataSource.external.provider = 'turso'
+ }
+
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(mockSqlConnection.connect).toHaveBeenCalled()
+ expect(result).toEqual([{ id: 1, name: 'SDK-Test-Result' }])
+ })
+
+ it('should throw error for unsupported external database type', async () => {
+ const unsupportedSource = {
+ ...mockDataSource,
+ external: {
+ dialect: 'oracle',
+ provider: 'oracle',
+ } as any,
+ }
+
+ await expect(
+ executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: unsupportedSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('Unsupported external database type')
+ })
+
+ it('should return an empty array if external connection is missing', async () => {
+ mockDataSource.external = undefined as any
+
+ const result = await executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toEqual([])
+ })
+
+ it('should handle database connection errors gracefully', async () => {
+ vi.mocked(mockSqlConnection.connect).mockRejectedValueOnce(
+ new Error('DB connection failed')
+ )
+
+ await expect(
+ executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('DB connection failed')
+ })
+
+ it('should handle query execution errors gracefully', async () => {
+ vi.mocked(mockSqlConnection.raw).mockRejectedValueOnce(
+ new Error('Query execution failed')
+ )
+
+ await expect(
+ executeSDKQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+ ).rejects.toThrow('Query execution failed')
+ })
+})
+
+describe('executeQuery Additional Branches', () => {
+ it('should handle isRaw = true and transform raw results', async () => {
+ const mockRawResult = {
+ columns: ['id', 'name'],
+ rows: [[1, 'Alice']],
+ meta: { rows_read: 1, rows_written: 0 },
+ }
+ ;(mockDataSource.rpc.executeQuery as any).mockResolvedValueOnce(
+ mockRawResult
+ )
+
+ const result = await executeQuery({
+ sql: 'SELECT * FROM users',
+ params: undefined,
+ isRaw: true,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toHaveProperty('columns')
+ expect(result).toHaveProperty('rows')
+ })
+
+ it('should execute query via hyperdrive source', async () => {
+ const hyperdriveSource: DataSource = {
+ source: 'hyperdrive',
+ external: {
+ connectionString: 'postgres://user:pass@localhost:5432/db',
+ } as any,
+ rpc: {} as any,
+ executionContext: {
+ waitUntil: vi.fn(),
+ } as any,
+ }
+
+ const result = await executeQuery({
+ sql: 'SELECT * FROM users',
+ params: [],
+ isRaw: false,
+ dataSource: hyperdriveSource,
+ config: mockConfig,
+ })
+
+ expect(result).toEqual([{ id: 1, name: 'Hyperdrive' }])
+ expect(hyperdriveSource.executionContext?.waitUntil).toHaveBeenCalled()
+ })
+
+ it('should execute query with plugins in registry', async () => {
+ const mockRegistry = {
+ beforeQuery: vi.fn().mockResolvedValue({
+ sql: 'SELECT * FROM users WHERE active = 1',
+ params: [],
+ }),
+ afterQuery: vi
+ .fn()
+ .mockImplementation(async ({ result }) => result),
+ } as any
+
+ const dataSourceWithPlugins: DataSource = {
+ ...mockDataSource,
+ registry: mockRegistry,
+ }
+
+ const result = await executeQuery({
+ sql: 'SELECT * FROM users',
+ params: undefined,
+ isRaw: false,
+ dataSource: dataSourceWithPlugins,
+ config: mockConfig,
+ })
+
+ expect(mockRegistry.beforeQuery).toHaveBeenCalled()
+ expect(mockRegistry.afterQuery).toHaveBeenCalled()
+ expect(result).toBeDefined()
+ })
+
+ it('should return empty array if internal RPC returns null', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ ;(mockDataSource.rpc.executeQuery as any).mockResolvedValueOnce(null)
+
+ const result = await executeQuery({
+ sql: 'SELECT * FROM users',
+ params: undefined,
+ isRaw: false,
+ dataSource: mockDataSource,
+ config: mockConfig,
+ })
+
+ expect(result).toEqual([])
+ })
+})
diff --git a/src/rls/index.test.ts b/src/rls/index.test.ts
index cf00156..18313c1 100644
--- a/src/rls/index.test.ts
+++ b/src/rls/index.test.ts
@@ -71,7 +71,8 @@ describe('loadPolicies - Policy Fetching and Parsing', () => {
describe('applyRLS - Query Modification', () => {
beforeEach(() => {
vi.resetAllMocks()
- mockDataSource.context.sub = 'user123'
+ mockConfig.role = 'client'
+ mockDataSource.context = { sub: 'user123' }
vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValue([
{
actions: 'SELECT',
@@ -82,6 +83,33 @@ describe('applyRLS - Query Modification', () => {
value_type: 'string',
operator: '=',
},
+ {
+ actions: 'DELETE',
+ schema: 'public',
+ table: 'users',
+ column: 'user_id',
+ value: 'context.id()',
+ value_type: 'string',
+ operator: '=',
+ },
+ {
+ actions: 'UPDATE',
+ schema: 'public',
+ table: 'users',
+ column: 'user_id',
+ value: 'context.id()',
+ value_type: 'string',
+ operator: '=',
+ },
+ {
+ actions: 'INSERT',
+ schema: 'public',
+ table: 'users',
+ column: 'user_id',
+ value: 1,
+ value_type: 'number',
+ operator: '=',
+ },
])
})
@@ -148,14 +176,14 @@ describe('applyRLS - Edge Cases', () => {
})
it('should not modify SQL if user is admin', async () => {
- mockConfig.role = 'admin'
+ const adminConfig = { ...mockConfig, role: 'admin' as const }
const sql = 'SELECT * FROM users'
const modifiedSql = await applyRLS({
sql,
isEnabled: true,
dataSource: mockDataSource,
- config: mockConfig,
+ config: adminConfig,
})
expect(modifiedSql).toBe(sql)
@@ -164,6 +192,8 @@ describe('applyRLS - Edge Cases', () => {
describe('applyRLS - Multi-Table Queries', () => {
beforeEach(() => {
+ mockConfig.role = 'client'
+ mockDataSource.context = { sub: 'user123' }
vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValue([
{
actions: 'SELECT',
@@ -200,8 +230,8 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})
- expect(modifiedSql).toContain("WHERE `users.user_id` = 'user123'")
- expect(modifiedSql).toContain("AND `orders.user_id` = 'user123'")
+ expect(modifiedSql).toContain("`users`.`user_id` = 'user123'")
+ expect(modifiedSql).toContain("`orders`.`user_id` = 'user123'")
})
it('should apply RLS policies to multiple tables in a JOIN', async () => {
@@ -218,8 +248,8 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})
- expect(modifiedSql).toContain("WHERE (users.user_id = 'user123')")
- expect(modifiedSql).toContain("AND (orders.user_id = 'user123')")
+ expect(modifiedSql).toContain("`users`.`user_id` = 'user123'")
+ expect(modifiedSql).toContain("`orders`.`user_id` = 'user123'")
})
it('should apply RLS policies to subqueries inside FROM clause', async () => {
@@ -236,6 +266,6 @@ describe('applyRLS - Multi-Table Queries', () => {
config: mockConfig,
})
- expect(modifiedSql).toContain("WHERE `users.user_id` = 'user123'")
+ expect(modifiedSql).toContain("`user_id` = 'user123'")
})
})
diff --git a/src/rls/index.ts b/src/rls/index.ts
index 68abb4e..7625d50 100644
--- a/src/rls/index.ts
+++ b/src/rls/index.ts
@@ -94,6 +94,8 @@ export async function loadPolicies(dataSource: DataSource): Promise {
let rightNode
if (value === 'context.id()') {
rightNode = { type: 'string', value: '__CONTEXT_ID__' }
+ } else if (typeof value === 'number') {
+ rightNode = { type: 'number', value: value }
} else {
rightNode = { type: 'string', value: value }
}
@@ -234,7 +236,10 @@ function applyRLSToAst(ast: any): void {
const tablesWithRules: Record = {}
policies.forEach((policy) => {
- const tbl = normalizeIdentifier(policy.condition.left.table)
+ let tbl = normalizeIdentifier(policy.condition.left.table)
+ if (tbl.includes('.')) {
+ tbl = tbl.split('.')[1]
+ }
if (!tablesWithRules[tbl]) {
tablesWithRules[tbl] = []
}
@@ -264,13 +269,16 @@ function applyRLSToAst(ast: any): void {
} else {
// SELECT or DELETE
tables =
- ast.from?.map((fromTable: any) => {
- let tableName = normalizeIdentifier(fromTable.table)
- if (tableName.includes('.')) {
- tableName = tableName.split('.')[1]
- }
- return tableName
- }) || []
+ ast.from
+ ?.map((fromTable: any) => {
+ if (!fromTable.table) return null
+ let tableName = normalizeIdentifier(fromTable.table)
+ if (tableName.includes('.')) {
+ tableName = tableName.split('.')[1]
+ }
+ return tableName
+ })
+ .filter(Boolean) || []
}
const restrictedTables = Object.keys(tablesWithRules)
@@ -291,32 +299,34 @@ function applyRLSToAst(ast: any): void {
(policy) => policy.action === statementType || policy.action === '*'
)
.forEach(({ action, condition }) => {
- const targetTable = normalizeIdentifier(condition.left.table)
+ let targetTable = normalizeIdentifier(condition.left.table)
+ if (targetTable.includes('.')) {
+ targetTable = targetTable.split('.')[1]
+ }
const isTargetTable = tables.includes(targetTable)
if (!isTargetTable) return
if (action !== 'INSERT') {
+ const conditionToApply = {
+ ...condition,
+ left: {
+ ...condition.left,
+ table: tables.length > 1 ? targetTable : null,
+ },
+ }
// Add condition to WHERE with parentheses
if (ast.where) {
ast.where = {
type: 'binary_expr',
operator: 'AND',
- parentheses: true,
left: {
...ast.where,
- parentheses: true,
- },
- right: {
- ...condition,
- parentheses: true,
},
+ right: conditionToApply,
}
} else {
- ast.where = {
- ...condition,
- parentheses: true,
- }
+ ast.where = conditionToApply
}
} else {
// For INSERT, enforce column values
@@ -349,8 +359,9 @@ function applyRLSToAst(ast: any): void {
})
ast.from?.forEach((fromItem: any) => {
- if (fromItem.expr && fromItem.expr.type === 'select') {
- applyRLSToAst(fromItem.expr)
+ const subAst = fromItem.expr?.ast || fromItem.expr
+ if (subAst && subAst.type === 'select') {
+ applyRLSToAst(subAst)
}
// Handle both single join and array of joins
@@ -359,8 +370,9 @@ function applyRLSToAst(ast: any): void {
? fromItem.join
: [fromItem]
joins.forEach((joinItem: any) => {
- if (joinItem.expr && joinItem.expr.type === 'select') {
- applyRLSToAst(joinItem.expr)
+ const joinSubAst = joinItem.expr?.ast || joinItem.expr
+ if (joinSubAst && joinSubAst.type === 'select') {
+ applyRLSToAst(joinSubAst)
}
})
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 8546114..40017bd 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,13 +1,22 @@
import { defineConfig } from 'vitest/config'
+import path from 'path'
export default defineConfig({
assetsInclude: ['**/*.sql'],
+ resolve: {
+ alias: {
+ 'cloudflare:workers': path.resolve(
+ __dirname,
+ './src/mocks/cloudflare-workers.ts'
+ ),
+ },
+ },
test: {
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'json', 'json-summary', 'lcov'],
include: ['src/**/*.ts'],
- exclude: ['**/node_modules/**'],
+ exclude: ['**/node_modules/**', 'src/mocks/**', '**/*.test.ts'],
reportOnFailure: true, // Ensures the report is generated even if tests fail
thresholds: {
lines: 75,