Skip to content
Closed
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
78 changes: 78 additions & 0 deletions plugins/sql-macros/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SqlMacrosPlugin } from './index'
import type { StarbaseApp } from '../../src/handler'
import type { DataSource } from '../../src/types'

describe('SqlMacrosPlugin', () => {
let plugin: SqlMacrosPlugin
let mockDataSource: DataSource

beforeEach(() => {
vi.clearAllMocks()

mockDataSource = {
source: 'internal',
rpc: {
executeQuery: vi.fn().mockResolvedValue([
{ column_name: 'id' },
{ column_name: 'name' },
{ column_name: 'password' },
]),
},
} as unknown as DataSource

plugin = new SqlMacrosPlugin({ preventSelectStar: true })
plugin.config = { role: 'client' }
})

it('should register middleware to capture config', async () => {
const mockApp = {
use: vi.fn((middleware) =>
middleware({ get: vi.fn(() => ({ role: 'admin' })) }, vi.fn())
),
} as unknown as StarbaseApp

await plugin.register(mockApp)

expect(plugin.config?.role).toBe('admin')
})

it('should throw error on SELECT * for non-admin when preventSelectStar is true', async () => {
await expect(
plugin.beforeQuery({
sql: 'SELECT * FROM users',
dataSource: mockDataSource,
})
).rejects.toThrow('SELECT * is not allowed. Please specify explicit columns.')
})

it('should allow SELECT * when user role is admin', async () => {
plugin.config = { role: 'admin' }

const res = await plugin.beforeQuery({
sql: 'SELECT * FROM users',
dataSource: mockDataSource,
})

expect(res.sql).toBe('SELECT * FROM users')
})

it('should expand $_exclude macro to non-excluded columns for internal dataSource', async () => {
const res = await plugin.beforeQuery({
sql: 'SELECT $_exclude(password) FROM users',
dataSource: mockDataSource,
})

expect(res.sql).toContain('id')
expect(res.sql).toContain('name')
expect(res.sql).not.toContain('password')
})

it('should return original SQL when dataSource is missing', async () => {
const res = await plugin.beforeQuery({
sql: 'SELECT * FROM users',
})

expect(res.sql).toBe('SELECT * FROM users')
})
})
116 changes: 116 additions & 0 deletions src/allowlist/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { isQueryAllowed } from './index'
import type { DataSource } from '../types'
import type { StarbaseDBConfiguration } from '../handler'

describe('Allowlist Module - isQueryAllowed', () => {
let mockDataSource: DataSource
let mockConfig: StarbaseDBConfiguration

beforeEach(() => {
vi.clearAllMocks()

mockDataSource = {
source: 'internal',
rpc: {
executeQuery: vi.fn().mockResolvedValue([
{
sql_statement: 'SELECT id, name FROM users;',
source: 'internal',
},
{
sql_statement: 'SELECT * FROM orders WHERE id = 1',
source: 'external',
},
]),
},
} as unknown as DataSource

mockConfig = {
role: 'client',
features: { allowlist: true },
}
})

it('should return true immediately when allowlist is disabled', async () => {
const res = await isQueryAllowed({
sql: 'DROP TABLE users',
isEnabled: false,
dataSource: mockDataSource,
config: mockConfig,
})

expect(res).toBe(true)
expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled()
})

it('should return true immediately when user role is admin', async () => {
mockConfig.role = 'admin'

const res = await isQueryAllowed({
sql: 'DELETE FROM users',
isEnabled: true,
dataSource: mockDataSource,
config: mockConfig,
})

expect(res).toBe(true)
expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled()
})

it('should return Error when sql is empty string', async () => {
const res = await isQueryAllowed({
sql: '',
isEnabled: true,
dataSource: mockDataSource,
config: mockConfig,
})

expect(res).toBeInstanceOf(Error)
expect((res as Error).message).toBe('No SQL provided for allowlist check')
})

it('should allow query that matches an allowlisted statement ignoring trailing semicolon', async () => {
const res = await isQueryAllowed({
sql: 'SELECT id, name FROM users',
isEnabled: true,
dataSource: mockDataSource,
config: mockConfig,
})

expect(res).toBe(true)
expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(1)
})

it('should reject query that is not in the allowlist and record it in tmp_allowlist_rejections', async () => {
await expect(
isQueryAllowed({
sql: 'SELECT * FROM secret_table',
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: ['SELECT * FROM secret_table', 'internal'],
})
})

it('should handle errors when loading allowlist and reject non-matching query', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockDataSource.rpc.executeQuery).mockRejectedValue(
new Error('RPC failure')
)

await expect(
isQueryAllowed({
sql: 'SELECT id, name FROM users',
isEnabled: true,
dataSource: mockDataSource,
config: mockConfig,
})
).rejects.toThrow('Query not allowed')
})
})
197 changes: 197 additions & 0 deletions src/import/csv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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()

mockDataSource = {
source: 'internal',
rpc: { executeQuery: vi.fn() },
} as unknown as DataSource

mockConfig = {
outerbaseApiKey: 'mock-api-key',
role: 'admin',
features: { allowlist: true, rls: true, rest: true },
}
})

describe('CSV Import Module - importTableFromCsvRoute', () => {
it('should return 400 when request body is empty', async () => {
const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(400)
const json = (await response.json()) as { error?: string }
expect(json.error).toBe('Request body is empty')
})

it('should return 400 for unsupported Content-Type', async () => {
const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
headers: { 'Content-Type': 'application/xml' },
body: '<xml></xml>',
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(400)
const json = (await response.json()) as { error?: string }
expect(json.error).toBe('Unsupported Content-Type')
})

it('should return 400 when CSV contains only header and no data rows', async () => {
const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: 'id,name',
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(400)
const json = (await response.json()) as { error?: string }
expect(json.error).toBe('Invalid CSV format or empty data')
})

it('should import raw text/csv body into the target table', async () => {
vi.mocked(executeOperation).mockResolvedValue([])

const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: 'id,name\n1,Alice\n2,Bob',
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(200)
const json = (await response.json()) as { result: { message: string } }
expect(json.result.message).toBe(
'Imported 2 out of 2 records successfully. 0 records failed.'
)
expect(executeOperation).toHaveBeenCalledTimes(2)
})

it('should import JSON-wrapped CSV data with columnMapping', async () => {
vi.mocked(executeOperation).mockResolvedValue([])

const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: 'user_id,full_name\n10,Charlie',
columnMapping: { user_id: 'id', full_name: 'name' },
}),
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(200)
expect(executeOperation).toHaveBeenCalledWith(
[
{
sql: 'INSERT INTO users (id, name) VALUES (?, ?)',
params: ['10', 'Charlie'],
},
],
mockDataSource,
mockConfig
)
})

it('should return 400 when multipart/form-data has no file', async () => {
const formData = new FormData()
const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
body: formData,
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(400)
const json = (await response.json()) as { error?: string }
expect(json.error).toBe('No file uploaded')
})

it('should record failed statements when executeOperation rejects for a row', async () => {
vi.mocked(executeOperation)
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error('Unique constraint failed'))

const request = new Request('http://localhost/import/csv/users', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: 'id,name\n1,Alice\n2,Bob',
})

const response = await importTableFromCsvRoute(
'users',
request,
mockDataSource,
mockConfig
)

expect(response.status).toBe(200)
const json = (await response.json()) as {
result: { message: string; failedStatements: any[] }
}
expect(json.result.message).toBe(
'Imported 1 out of 2 records successfully. 1 records failed.'
)
expect(json.result.failedStatements).toHaveLength(1)
})
})