Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/allowlist/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = []): 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)
})
})
167 changes: 167 additions & 0 deletions src/import/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})