diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts new file mode 100644 index 0000000..77a8da0 --- /dev/null +++ b/src/allowlist/index.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isQueryAllowed } from './index' +import type { DataSource } from '../types' +import type { StarbaseDBConfiguration } from '../handler' + +// Mock the node-sql-parser module used inside allowlist/index.ts +vi.mock('node-sql-parser', () => { + const Parser = vi.fn().mockImplementation(() => ({ + astify: vi.fn((sql: string) => { + // Return a simple deterministic AST keyed by the normalized SQL + return { type: 'select', table: sql, columns: [] } + }), + })) + return { Parser } +}) + +function makeMockDataSource(rows: Record[] = []): DataSource { + return { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockResolvedValue(rows), + }, + } as unknown as DataSource +} + +function makeConfig(role: 'admin' | 'client' = 'client'): StarbaseDBConfiguration { + return { + outerbaseApiKey: 'test-key', + role, + features: { allowlist: true, rls: false, rest: false }, + } as StarbaseDBConfiguration +} + +describe('isQueryAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns true when allowlist feature is disabled', async () => { + const dataSource = makeMockDataSource() + const config = makeConfig() + const result = await isQueryAllowed({ + sql: 'SELECT * FROM users', + isEnabled: false, + dataSource, + config, + }) + expect(result).toBe(true) + // dataSource should not be queried when feature is off + expect(dataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('returns true for admin role even when allowlist is enabled', async () => { + const dataSource = makeMockDataSource([]) + const config = makeConfig('admin') + const result = await isQueryAllowed({ + sql: 'SELECT * FROM secrets', + isEnabled: true, + dataSource, + config, + }) + expect(result).toBe(true) + // Admin bypass must not hit the allowlist DB + expect(dataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('throws when no SQL is provided', async () => { + const dataSource = makeMockDataSource([]) + const config = makeConfig() + await expect( + isQueryAllowed({ + sql: '', + isEnabled: true, + dataSource, + config, + }) + ).rejects.toThrow('No SQL provided for allowlist check') + }) + + it('throws for a query that is not in the allowlist', async () => { + // allowlist returns a row whose sql_statement parses to a *different* AST + const dataSource = makeMockDataSource([ + { sql_statement: 'SELECT id FROM users', source: 'internal' }, + ]) + const config = makeConfig() + await expect( + isQueryAllowed({ + sql: 'DROP TABLE users', + isEnabled: true, + dataSource, + config, + }) + ).rejects.toThrow() + }) + + it('returns true when the query matches an allowlist entry', async () => { + const sql = 'SELECT id FROM users' + // The mock parser returns { type: 'select', table: sql, columns: [] } + // so both the allowlist row and the query will produce identical ASTs + const dataSource = makeMockDataSource([ + { sql_statement: sql, source: 'internal' }, + ]) + const config = makeConfig() + const result = await isQueryAllowed({ + sql, + isEnabled: true, + dataSource, + config, + }) + expect(result).toBe(true) + }) + + it('filters allowlist rows by matching source field', async () => { + // Row from a different source should be excluded; effective allowlist = empty + const dataSource = { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockResolvedValue([ + { sql_statement: 'SELECT id FROM users', source: 'external' }, + ]), + }, + } as unknown as DataSource + const config = makeConfig() + await expect( + isQueryAllowed({ + sql: 'SELECT id FROM users', + isEnabled: true, + dataSource, + config, + }) + ).rejects.toThrow() + }) + + it('handles loadAllowlist DB errors gracefully (falls back to empty list)', async () => { + const dataSource = { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockRejectedValue(new Error('DB down')), + }, + } as unknown as DataSource + const config = makeConfig() + // Empty allowlist → query is not allowed → should throw + await expect( + isQueryAllowed({ + sql: 'SELECT 1', + isEnabled: true, + dataSource, + config, + }) + ).rejects.toThrow() + }) + + it('normalizeSQL strips trailing semicolons before AST comparison', async () => { + const sqlWithSemicolon = 'SELECT id FROM users;' + const sqlWithoutSemicolon = 'SELECT id FROM users' + // Both are normalized to the same string by the time astify is called, + // so the parser receives the same input and returns equal ASTs. + const dataSource = makeMockDataSource([ + { sql_statement: sqlWithoutSemicolon, source: 'internal' }, + ]) + const config = makeConfig() + // Should resolve to true because normalization makes them equal + const result = await isQueryAllowed({ + sql: sqlWithSemicolon, + isEnabled: true, + dataSource, + config, + }) + expect(result).toBe(true) + }) +}) diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts new file mode 100644 index 0000000..28c13e5 --- /dev/null +++ b/src/import/csv.test.ts @@ -0,0 +1,167 @@ +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: unknown, message: string | undefined, status: number) => + new Response(JSON.stringify({ result: data, error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ), +})) + +const mockDataSource: DataSource = { + source: 'internal', + rpc: { executeQuery: vi.fn() }, +} as unknown as DataSource + +const mockConfig: StarbaseDBConfiguration = { + outerbaseApiKey: 'key', + role: 'admin', + features: { allowlist: false, rls: false, rest: false }, +} as StarbaseDBConfiguration + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('importTableFromCsvRoute', () => { + it('returns 400 when the request body is missing', async () => { + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(400) + }) + + it('returns 400 for an unsupported Content-Type', async () => { + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'some data', + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(400) + const body = await res.json() as any + expect(body.error).toMatch(/Unsupported Content-Type/i) + }) + + it('returns 400 when multipart/form-data has no "file" field', async () => { + const formData = new FormData() + // Deliberately do not append a 'file' field + formData.append('other', 'value') + const req = new Request('http://localhost', { + method: 'POST', + body: formData, + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(400) + const body = await res.json() as any + expect(body.error).toMatch(/No file uploaded/i) + }) + + it('returns 400 when the CSV data is empty or invalid', async () => { + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: '', + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(400) + const body = await res.json() as any + expect(body.error).toMatch(/Invalid CSV format or empty data/i) + }) + + it('imports a raw text/csv payload successfully', async () => { + vi.mocked(executeOperation).mockResolvedValue({ result: [], error: null } as any) + const csv = 'id,name\n1,Alice\n2,Bob' + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csv, + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.result.message).toMatch(/Imported 2 out of 2/) + expect(executeOperation).toHaveBeenCalledTimes(2) + }) + + it('imports a JSON-wrapped CSV payload with column mapping', async () => { + vi.mocked(executeOperation).mockResolvedValue({ result: [], error: null } as any) + const payload = JSON.stringify({ + data: 'user_id,full_name\n10,Carol', + columnMapping: { user_id: 'id', full_name: 'name' }, + }) + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + }) + const res = await importTableFromCsvRoute('people', req, mockDataSource, mockConfig) + expect(res.status).toBe(200) + // The INSERT should reference mapped column names + const [ops] = vi.mocked(executeOperation).mock.calls[0] as any[] + expect(ops[0].sql).toContain('id') + expect(ops[0].sql).toContain('name') + }) + + it('imports a CSV file uploaded via multipart/form-data', async () => { + vi.mocked(executeOperation).mockResolvedValue({ result: [], error: null } as any) + const csvContent = 'id,value\n42,hello' + const file = new File([csvContent], 'data.csv', { type: 'text/csv' }) + const formData = new FormData() + formData.append('file', file) + const req = new Request('http://localhost', { + method: 'POST', + body: formData, + }) + const res = await importTableFromCsvRoute('items', req, mockDataSource, mockConfig) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.result.message).toMatch(/Imported 1 out of 1/) + }) + + it('reports failed rows without aborting the whole import', async () => { + vi.mocked(executeOperation) + .mockResolvedValueOnce({ result: [], error: null } as any) // row 1 succeeds + .mockRejectedValueOnce(new Error('UNIQUE constraint failed')) // row 2 fails + const csv = 'id,name\n1,Alice\n2,Bob' + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csv, + }) + const res = await importTableFromCsvRoute('users', req, mockDataSource, mockConfig) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.result.message).toMatch(/Imported 1 out of 2/) + expect(body.result.failedStatements).toHaveLength(1) + expect(body.result.failedStatements[0].error).toMatch(/UNIQUE constraint/) + }) + + it('generates correct INSERT SQL with the table name and columns', async () => { + vi.mocked(executeOperation).mockResolvedValue({ result: [], error: null } as any) + const csv = 'email,age\ntest@example.com,30' + const req = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csv, + }) + await importTableFromCsvRoute('accounts', req, mockDataSource, mockConfig) + const [ops] = vi.mocked(executeOperation).mock.calls[0] as any[] + expect(ops[0].sql).toMatch(/INSERT INTO accounts/) + expect(ops[0].sql).toContain('email') + expect(ops[0].sql).toContain('age') + expect(ops[0].params).toEqual(['test@example.com', '30']) + }) +})