diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts new file mode 100644 index 0000000..9ce0435 --- /dev/null +++ b/src/allowlist/index.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isQueryAllowed } from './index' +import type { DataSource } from '../types' +import type { StarbaseDBConfiguration } from '../handler' + +vi.mock('node-sql-parser', () => ({ + Parser: class { + astify(sql: string) { + return { type: 'select', sql } + } + }, +})) + +let mockDataSource: DataSource +let mockConfig: StarbaseDBConfiguration + +beforeEach(() => { + vi.clearAllMocks() + + mockDataSource = { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'internal' }, + ]), + }, + } as any + + mockConfig = { + outerbaseApiKey: 'test-key', + role: 'client', + features: { allowlist: false, rls: false, rest: true }, + } +}) + +describe('isQueryAllowed', () => { + it('should return true when allowlist is not enabled', async () => { + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBe(true) + }) + + it('should return true for admin role regardless of allowlist', async () => { + mockConfig.role = 'admin' + + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBe(true) + }) + + it('should return error when SQL is empty', async () => { + const result = await isQueryAllowed({ + sql: '', + isEnabled: true, + dataSource: mockDataSource, + config: { ...mockConfig, role: 'client' }, + }) + + expect(result instanceof Error).toBe(true) + expect((result as Error).message).toBe('No SQL provided for allowlist check') + }) + + it('should throw error when query is not in allowlist', async () => { + await expect( + isQueryAllowed({ + sql: 'DROP TABLE users', + isEnabled: true, + dataSource: mockDataSource, + config: { ...mockConfig, role: 'client' }, + }) + ).rejects.toThrow('Query not allowed') + }) + + it('should handle errors from loadAllowlist gracefully', async () => { + mockDataSource.rpc.executeQuery = vi.fn().mockRejectedValue(new Error('DB error')) + + await expect( + isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: true, + dataSource: mockDataSource, + config: { ...mockConfig, role: 'client' }, + }) + ).rejects.toThrow() + }) + + it('should normalize trailing semicolons from SQL', async () => { + mockDataSource.rpc.executeQuery = vi.fn().mockResolvedValue([ + { sql_statement: 'SELECT * FROM users', source: 'internal' }, + ]) + + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users;', + isEnabled: true, + dataSource: mockDataSource, + config: { ...mockConfig, role: 'client' }, + }) + + expect(result).toBe(true) + }) +}) diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts new file mode 100644 index 0000000..29b9dd6 --- /dev/null +++ b/src/import/csv.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { importTableFromCsvRoute, parseCSV, mapRecord } 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: 'external', + external: { dialect: 'sqlite' }, + rpc: { executeQuery: vi.fn() }, + } as any + + mockConfig = { + outerbaseApiKey: 'mock-api-key', + role: 'admin', + features: { allowlist: true, rls: true, rest: true }, + } +}) + +describe('parseCSV', () => { + it('should parse a basic CSV string into records', () => { + const csv = 'name,age\nAlice,30\nBob,25' + const result = parseCSV(csv) + expect(result).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]) + }) + + it('should handle trailing newline', () => { + const csv = 'name,age\nAlice,30\n' + const result = parseCSV(csv) + expect(result).toEqual([{ name: 'Alice', age: '30' }]) + }) + + it('should handle extra whitespace in values', () => { + const csv = 'name, age\nAlice, 30\nBob, 25' + const result = parseCSV(csv) + expect(result).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]) + }) + + it('should skip rows with incorrect column count', () => { + const csv = 'name,age\nAlice,30,extra\nBob,25' + const result = parseCSV(csv) + expect(result).toEqual([{ name: 'Bob', age: '25' }]) + }) + + it('should return empty array for single-line CSV (headers only)', () => { + const csv = 'name,age' + const result = parseCSV(csv) + expect(result).toEqual([]) + }) + + it('should handle empty CSV string', () => { + const result = parseCSV('') + expect(result).toEqual([]) + }) +}) + +describe('mapRecord', () => { + it('should map columns using the provided mapping', () => { + const record = { name: 'Alice', yearsOld: '30' } + const mapping = { yearsOld: 'age' } + const result = mapRecord(record, mapping) + expect(result).toEqual({ name: 'Alice', age: '30' }) + }) + + it('should keep original key when no mapping exists', () => { + const record = { name: 'Alice', age: '30' } + const result = mapRecord(record, {}) + expect(result).toEqual({ name: 'Alice', age: '30' }) + }) + + it('should handle empty record', () => { + const result = mapRecord({}, {}) + expect(result).toEqual({}) + }) +}) + +describe('CSV Import Route', () => { + it('should return 400 for empty request body', async () => { + const request = new Request('http://localhost', { + method: 'POST', + body: null as any, + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + }) + + it('should return 400 for unsupported Content-Type', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'some data', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + const jsonResponse = (await response.json()) as { error?: string } + expect(jsonResponse.error).toBe('Unsupported Content-Type') + }) + + it('should return 400 for invalid CSV content via JSON', 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) + const jsonResponse = (await response.json()) as { error?: string } + expect(jsonResponse.error).toBe('Invalid CSV format or empty data') + }) + + it('should return 400 for no file uploaded in multipart form-data', async () => { + const formData = new FormData() + const request = new Request('http://localhost', { + method: 'POST', + body: formData, + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + const jsonResponse = (await response.json()) as { error?: string } + expect(jsonResponse.error).toBe('No file uploaded') + }) + + it('should successfully insert CSV records via JSON content type', async () => { + vi.mocked(executeOperation).mockResolvedValue([]) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'name,age\nAlice,30\nBob,25', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const jsonResponse = (await response.json()) as { + result: { message: string } + } + expect(jsonResponse.result.message).toBe( + 'Imported 2 out of 2 records successfully. 0 records failed.' + ) + expect(executeOperation).toHaveBeenCalledTimes(2) + }) + + it('should handle column mapping during CSV import', async () => { + vi.mocked(executeOperation).mockResolvedValue([]) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'full_name,email\nAlice Smith,alice@test.com', + columnMapping: { full_name: 'name' }, + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const calls = vi.mocked(executeOperation).mock.calls + expect(calls[0][0][0].sql).toContain('name') + expect(calls[0][0][0].sql).toContain('email') + }) + + it('should handle raw text/csv content type', async () => { + vi.mocked(executeOperation).mockResolvedValue([]) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: 'name,age\nAlice,30', + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + }) + + it('should return partial success if some inserts fail', async () => { + vi.mocked(executeOperation) + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('Database Error')) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'name,age\nAlice,30\nBob,25', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const jsonResponse = (await response.json()) as { + result: { message: string; failedStatements: any[] } + } + expect(jsonResponse.result.message).toBe( + 'Imported 1 out of 2 records successfully. 1 records failed.' + ) + expect(jsonResponse.result.failedStatements.length).toBe(1) + }) + + it('should return 200 with failure count when all inserts fail', async () => { + vi.mocked(executeOperation).mockRejectedValue( + new Error('Database error') + ) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'name\nAlice\nBob', + }), + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(200) + const jsonResponse = (await response.json()) as { + result: { message: string; failedStatements: any[] } + } + expect(jsonResponse.result.message).toBe( + 'Imported 0 out of 2 records successfully. 2 records failed.' + ) + expect(jsonResponse.result.failedStatements.length).toBe(2) + }) +}) diff --git a/src/import/csv.ts b/src/import/csv.ts index aaf9e86..d30ffb3 100644 --- a/src/import/csv.ts +++ b/src/import/csv.ts @@ -110,7 +110,7 @@ export async function importTableFromCsvRoute( } } -function parseCSV(csv: string): Record[] { +export function parseCSV(csv: string): Record[] { const lines = csv.split('\n') const headers = lines[0].split(',').map((header) => header.trim()) const records: Record[] = [] @@ -129,7 +129,7 @@ function parseCSV(csv: string): Record[] { return records } -function mapRecord(record: any, columnMapping: ColumnMapping): any { +export function mapRecord(record: any, columnMapping: ColumnMapping): any { const mappedRecord: any = {} for (const [key, value] of Object.entries(record)) { const mappedKey = columnMapping[key] || key diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..57c0391 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('jose', () => ({ + jwtVerify: vi.fn(), + createRemoteJWKSet: vi.fn(), +})) + +vi.mock('./handler', () => ({ + StarbaseDB: vi.fn().mockImplementation(() => ({ + handlePreAuth: vi.fn().mockResolvedValue(null), + handle: vi.fn().mockResolvedValue(new Response('OK', { status: 200 })), + })), + StarbaseDBConfiguration: {}, +})) + +vi.mock('./do', () => ({ + StarbaseDBDurableObject: class {}, +})) + +vi.mock('node-sql-parser', () => ({ + Parser: class { + astify() { + return { type: 'select' } + } + }, +})) + +let module: any +let handler: any + +beforeEach(async () => { + vi.clearAllMocks() + vi.resetModules() + module = await import('./index') + handler = module.default +}) + +describe('Worker Entry Point', () => { + it('should export a fetch handler', () => { + expect(handler).toBeDefined() + expect(typeof handler.fetch).toBe('function') + }) + + it('should return 401 when no Authorization header is provided', async () => { + const env = { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockReturnValue('id'), + get: vi.fn().mockReturnValue({ + init: vi.fn().mockResolvedValue({ + executeQuery: vi.fn(), + }), + }), + }, + } + + const request = new Request('http://localhost/api/query') + const response = await handler.fetch(request, env, {}) + + expect(response.status).toBe(401) + }) + + it('should authorize with admin token', async () => { + const mockInit = vi.fn().mockResolvedValue({ executeQuery: vi.fn() }) + const env = { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockReturnValue('id'), + get: vi.fn().mockReturnValue({ init: mockInit }), + }, + } + + const request = new Request('http://localhost/api/query', { + headers: { Authorization: 'Bearer admin-token' }, + }) + + const response = await handler.fetch(request, env, {}) + + expect(response.status).not.toBe(401) + expect(mockInit).toHaveBeenCalledOnce() + }) + + it('should authorize with client token', async () => { + const mockInit = vi.fn().mockResolvedValue({ executeQuery: vi.fn() }) + const env = { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockReturnValue('id'), + get: vi.fn().mockReturnValue({ init: mockInit }), + }, + } + + const request = new Request('http://localhost/api/query', { + headers: { Authorization: 'Bearer client-token' }, + }) + + const response = await handler.fetch(request, env, {}) + + expect(response.status).not.toBe(401) + }) + + it('should handle OPTIONS preflight requests', async () => { + const env = { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockReturnValue('id'), + get: vi.fn().mockReturnValue({ + init: vi.fn().mockResolvedValue({ executeQuery: vi.fn() }), + }), + }, + } + + const request = new Request('http://localhost/api/query', { + method: 'OPTIONS', + }) + + const response = await handler.fetch(request, env, {}) + + expect([200, 204]).toContain(response.status) + }) + + it('should handle errors gracefully and return 400', async () => { + const env = { + ADMIN_AUTHORIZATION_TOKEN: 'admin-token', + CLIENT_AUTHORIZATION_TOKEN: 'client-token', + DATABASE_DURABLE_OBJECT: { + idFromName: vi.fn().mockImplementation(() => { + throw new Error('DO error') + }), + get: vi.fn(), + }, + } + + const request = new Request('http://localhost/api/query', { + headers: { Authorization: 'Bearer admin-token' }, + }) + + const response = await handler.fetch(request, env, {}) + + expect(response.status).toBe(400) + const json = (await response.json()) as { error?: string } + expect(json.error).toBe('DO error') + }) +})