From 8075648609250ffe5f633cc69645d4aec6b14a84 Mon Sep 17 00:00:00 2001 From: jeffrey-graham Date: Fri, 6 Nov 2026 18:48:41 -0500 Subject: [PATCH 1/3] Create index.test.ts --- src/allowlist/index.test.ts | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/allowlist/index.test.ts diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts new file mode 100644 index 0000000..f4825a6 --- /dev/null +++ b/src/allowlist/index.test.ts @@ -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') + }) +}) From cf25a35c93858b67881a980cf87e3441de059564 Mon Sep 17 00:00:00 2001 From: jeffrey-graham Date: Fri, 6 Nov 2026 18:49:25 -0500 Subject: [PATCH 2/3] Create csv.test.ts --- src/import/csv.test.ts | 197 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/import/csv.test.ts diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts new file mode 100644 index 0000000..07ddd0b --- /dev/null +++ b/src/import/csv.test.ts @@ -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: '', + }) + + 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) + }) +}) From 128840ec92335a88fc44263138d18178e4adbb80 Mon Sep 17 00:00:00 2001 From: jeffrey-graham Date: Fri, 6 Nov 2026 18:50:12 -0500 Subject: [PATCH 3/3] Create index.test.ts --- plugins/sql-macros/index.test.ts | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 plugins/sql-macros/index.test.ts diff --git a/plugins/sql-macros/index.test.ts b/plugins/sql-macros/index.test.ts new file mode 100644 index 0000000..a25dab3 --- /dev/null +++ b/plugins/sql-macros/index.test.ts @@ -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') + }) +})