From 12dad152c6d116ff98c55fe391cf9d378ae6cb8d Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:09:24 +0100 Subject: [PATCH 1/6] fix(sql-macros): normalize AST array in checkSelectStar --- plugins/sql-macros/index.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/sql-macros/index.ts b/plugins/sql-macros/index.ts index 9dfbb85..90a0197 100644 --- a/plugins/sql-macros/index.ts +++ b/plugins/sql-macros/index.ts @@ -59,16 +59,20 @@ export class SqlMacrosPlugin extends StarbasePlugin { private checkSelectStar(sql: string, params?: unknown[]): string { try { - const ast = parser.astify(sql)[0] + const rawAst = parser.astify(sql) + const ast = Array.isArray(rawAst) ? rawAst[0] : rawAst // Only check SELECT statements - if (ast.type === 'select') { - const hasSelectStar = ast.columns.some( - (col: any) => - col.expr.type === 'star' || - (col.expr.type === 'column_ref' && - col.expr.column === '*') - ) + if (ast && ast.type === 'select') { + const hasSelectStar = + ast.columns === '*' || + (Array.isArray(ast.columns) && + ast.columns.some( + (col: any) => + col.expr?.type === 'star' || + (col.expr?.type === 'column_ref' && + col.expr?.column === '*') + )) if (hasSelectStar) { throw new Error( @@ -116,10 +120,13 @@ export class SqlMacrosPlugin extends StarbasePlugin { '$_exclude', '__exclude' ) - const normalizedQuery = parser.astify(preparedSql)[0] + const rawQuery = parser.astify(preparedSql) + const normalizedQuery = Array.isArray(rawQuery) + ? rawQuery[0] + : rawQuery // Only process SELECT statements - if (normalizedQuery.type !== 'select') { + if (!normalizedQuery || normalizedQuery.type !== 'select') { return sql } From 528e7c087b94a203b7bb75d7cda597c57ed53bef Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:10:28 +0100 Subject: [PATCH 2/6] test(allowlist): add Vitest unit tests for allowlist table and procedure rules --- src/allowlist/index.test.ts | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 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..f9a2e8f --- /dev/null +++ b/src/allowlist/index.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import { isQueryAllowed } from './index' +import { DataSource } from '../types' +import { StarbaseDBConfiguration } from '../handler' + +function createMockDataSource( + allowedQueries: Array<{ sql_statement: string; source: string }> = [], + source = 'internal' +): DataSource { + return { + source, + rpc: { + executeQuery: vi.fn(async ({ sql }: { sql: string; params?: any[] }) => { + if (sql.includes('SELECT sql_statement, source FROM tmp_allowlist_queries')) { + return allowedQueries + } + return [] + }), + executeTransaction: vi.fn(async () => []), + setAlarm: vi.fn(async () => {}), + getAlarm: vi.fn(async () => null), + deleteAlarm: vi.fn(async () => {}), + getStatistics: vi.fn(async () => ({})), + } as any, + } +} + +describe('Allowlist (src/allowlist/index.ts)', () => { + const userConfig: StarbaseDBConfiguration = { + role: 'user', + } as any + + const adminConfig: StarbaseDBConfiguration = { + role: 'admin', + } as any + + it('allows any query when allowlist is disabled', async () => { + const ds = createMockDataSource() + const allowed = await isQueryAllowed({ + sql: 'DROP TABLE users;', + isEnabled: false, + dataSource: ds, + config: userConfig, + }) + expect(allowed).toBe(true) + expect(ds.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('allows any query when user role is admin', async () => { + const ds = createMockDataSource() + const allowed = await isQueryAllowed({ + sql: 'DELETE FROM users', + isEnabled: true, + dataSource: ds, + config: adminConfig, + }) + expect(allowed).toBe(true) + expect(ds.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('allows query matching AST in allowlist even with trailing semicolon difference', async () => { + const ds = createMockDataSource([ + { + sql_statement: 'SELECT id, name FROM users WHERE id = 1', + source: 'internal', + }, + ]) + const allowed = await isQueryAllowed({ + sql: 'SELECT id, name FROM users WHERE id = 1;', + isEnabled: true, + dataSource: ds, + config: userConfig, + }) + expect(allowed).toBe(true) + }) + + it('rejects non-matching query and logs it into tmp_allowlist_rejections', async () => { + const ds = createMockDataSource([ + { + sql_statement: 'SELECT id FROM users', + source: 'internal', + }, + ]) + + await expect( + isQueryAllowed({ + sql: 'SELECT id, secret FROM users', + isEnabled: true, + dataSource: ds, + config: userConfig, + }) + ).rejects.toThrow('Query not allowed') + + expect(ds.rpc.executeQuery).toHaveBeenCalledWith({ + sql: 'INSERT INTO tmp_allowlist_rejections (sql_statement, source) VALUES (?, ?)', + params: ['SELECT id, secret FROM users', 'internal'], + }) + }) + + it('returns an Error object when sql is empty', async () => { + const ds = createMockDataSource([]) + const res = await isQueryAllowed({ + sql: '', + isEnabled: true, + dataSource: ds, + config: userConfig, + }) + expect(res).toBeInstanceOf(Error) + expect((res as Error).message).toContain('No SQL provided') + }) +}) From 2527482bfee46404fef907e120694c4299096fd6 Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:11:22 +0100 Subject: [PATCH 3/6] test(import): add Vitest unit tests for CSV import --- src/import/csv.test.ts | 116 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 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..b68e14c --- /dev/null +++ b/src/import/csv.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { importTableFromCsvRoute } from './csv' +import { DataSource } from '../types' +import { StarbaseDBConfiguration } from '../handler' + +function createCsvMockDataSource(failOnValue?: string): DataSource { + return { + source: 'internal', + rpc: { + executeQuery: vi.fn(async ({ params }: { sql: string; params?: any[] }) => { + if (failOnValue && params?.includes(failOnValue)) { + throw new Error('Constraint violation') + } + return [] + }), + executeTransaction: vi.fn(async ({ operations }: { operations: any[] }) => { + for (const op of operations) { + if (failOnValue && op.params?.includes(failOnValue)) { + throw new Error('Constraint violation') + } + } + return [] + }), + } as any, + } +} + +describe('CSV Import Route (src/import/csv.ts)', () => { + const config: StarbaseDBConfiguration = { role: 'admin' } as any + + it('returns 400 when request body is empty', async () => { + const req = new Request('https://example.com/import/csv', { + method: 'POST', + }) + const res = await importTableFromCsvRoute( + 'users', + req, + createCsvMockDataSource(), + config + ) + expect(res.status).toBe(400) + const json: any = await res.json() + expect(json.error).toContain('Request body is empty') + }) + + it('returns 400 for unsupported Content-Type', async () => { + const req = new Request('https://example.com/import/csv', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'id,name\n1,Alice', + }) + const res = await importTableFromCsvRoute( + 'users', + req, + createCsvMockDataSource(), + config + ) + expect(res.status).toBe(400) + const json: any = await res.json() + expect(json.error).toContain('Unsupported Content-Type') + }) + + it('imports raw text/csv body successfully', async () => { + const ds = createCsvMockDataSource() + const req = new Request('https://example.com/import/csv', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: 'id,name\n1,Alice\n2,Bob', + }) + const res = await importTableFromCsvRoute('users', req, ds, config) + expect(res.status).toBe(200) + const json: any = await res.json() + expect(json.result.message).toBe( + 'Imported 2 out of 2 records successfully. 0 records failed.' + ) + expect(json.result.failedStatements).toHaveLength(0) + }) + + it('supports application/json with columnMapping and tracks failed records', async () => { + const ds = createCsvMockDataSource('Bob') + const req = new Request('https://example.com/import/csv', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'user_id,full_name\n1,Alice\n2,Bob', + columnMapping: { + user_id: 'id', + full_name: 'name', + }, + }), + }) + const res = await importTableFromCsvRoute('users', req, ds, config) + expect(res.status).toBe(200) + const json: any = await res.json() + expect(json.result.message).toBe( + 'Imported 1 out of 2 records successfully. 1 records failed.' + ) + expect(json.result.failedStatements).toHaveLength(1) + expect(json.result.failedStatements[0].statement).toContain( + 'INSERT INTO users (id, name) VALUES (?, ?)' + ) + }) + + it('returns 400 when CSV contains headers only and no valid rows', async () => { + const ds = createCsvMockDataSource() + const req = new Request('https://example.com/import/csv', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: 'id,name', + }) + const res = await importTableFromCsvRoute('users', req, ds, config) + expect(res.status).toBe(400) + const json: any = await res.json() + expect(json.error).toContain('Invalid CSV format or empty data') + }) +}) From a7a8ece544fa4cd8f33015e3ac22e5098b9e3db0 Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:12:14 +0100 Subject: [PATCH 4/6] test(cron): add Vitest unit tests for CronPlugin --- plugins/cron/index.test.ts | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 plugins/cron/index.test.ts diff --git a/plugins/cron/index.test.ts b/plugins/cron/index.test.ts new file mode 100644 index 0000000..56927a8 --- /dev/null +++ b/plugins/cron/index.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { CronPlugin, CronEventPayload } from './index' +import { getNextExecutionTime, parseCronExpression } from './utils' + +describe('CronPlugin & Cron Utils (plugins/cron)', () => { + it('parses cron expressions and computes next execution timestamp', () => { + const parsed = parseCronExpression('*/5 * * * *') + expect(parsed).toBeDefined() + + const baseTime = new Date('2026-01-01T00:00:00Z').getTime() + const nextMs = getNextExecutionTime('*/5 * * * *', baseTime) + expect(nextMs).toBe(new Date('2026-01-01T00:05:00Z').getTime()) + }) + + it('registers routes, schedules next alarm on addEvent, and dispatches /cron/callback events', async () => { + const storedTasks: Array<{ + name: string + cron_tab: string + payload: string + }> = [] + const setAlarmMock = vi.fn(async () => {}) + const executeQueryMock = vi.fn(async ({ sql, params }: { sql: string; params?: any[] }) => { + if (sql.includes('INSERT OR REPLACE INTO tmp_cron_tasks') && params) { + storedTasks.push({ + name: params[0], + cron_tab: params[1], + payload: params[2], + }) + return [] + } + if (sql.includes('SELECT name, cron_tab, payload')) { + return storedTasks + } + return [] + }) + + const mockDataSource = { + source: 'internal', + rpc: { + executeQuery: executeQueryMock, + setAlarm: setAlarmMock, + }, + } + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('dataSource', mockDataSource) + await next() + }) + + const plugin = new CronPlugin() + await plugin.register(app as any) + + const receivedEvents: CronEventPayload[] = [] + const waitUntilMock = vi.fn() + plugin.onEvent( + async (payload) => { + receivedEvents.push(payload) + }, + { waitUntil: waitUntilMock } as any + ) + + // Trigger middleware to initialize plugin dataSource + const res = await app.request('/cron/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { + name: 'hourly-sync', + cron_tab: '0 * * * *', + payload: { target: 'analytics' }, + }, + ]), + }) + + expect(res.status).toBe(200) + expect(receivedEvents).toHaveLength(1) + expect(receivedEvents[0].name).toBe('hourly-sync') + expect(waitUntilMock).toHaveBeenCalledTimes(1) + + await plugin.addEvent('*/10 * * * *', 'cleanup-job', { dryRun: false }, 'https://example.com') + expect(setAlarmMock).toHaveBeenCalledTimes(1) + }) +}) From f2a82f1ba8dddffa682ebc5df40f67453294ef13 Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:13:09 +0100 Subject: [PATCH 5/6] test(sql-macros): add Vitest unit tests for SqlMacrosPlugin --- 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..c1d98ae --- /dev/null +++ b/plugins/sql-macros/index.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { SqlMacrosPlugin } from './index' +import { DataSource } from '../../src/types' + +function createSchemaMockDataSource(columns: string[], source = 'internal'): DataSource { + return { + source, + rpc: { + executeQuery: vi.fn(async () => + columns.map((col) => ({ column_name: col })) + ), + } as any, + } +} + +describe('SqlMacrosPlugin (plugins/sql-macros)', () => { + it('blocks SELECT * for non-admin users when preventSelectStar is enabled', async () => { + const plugin = new SqlMacrosPlugin({ preventSelectStar: true }) + plugin.config = { role: 'user' } as any + const ds = createSchemaMockDataSource(['id', 'email']) + + await expect( + plugin.beforeQuery({ + sql: 'SELECT * FROM users', + dataSource: ds, + }) + ).rejects.toThrow('SELECT * is not allowed. Please specify explicit columns.') + + await expect( + plugin.beforeQuery({ + sql: 'SELECT users.* FROM users', + dataSource: ds, + }) + ).rejects.toThrow('SELECT * is not allowed. Please specify explicit columns.') + }) + + it('allows SELECT * for admin users even when preventSelectStar is enabled', async () => { + const plugin = new SqlMacrosPlugin({ preventSelectStar: true }) + plugin.config = { role: 'admin' } as any + const ds = createSchemaMockDataSource(['id', 'email']) + + const res = await plugin.beforeQuery({ + sql: 'SELECT * FROM users', + dataSource: ds, + }) + expect(res.sql).toBe('SELECT * FROM users') + }) + + it('expands $_exclude macro into explicit table columns excluding specified columns', async () => { + const plugin = new SqlMacrosPlugin({ preventSelectStar: false }) + const ds = createSchemaMockDataSource(['id', 'name', 'password_hash']) + + const res = await plugin.beforeQuery({ + sql: 'SELECT $_exclude(password_hash) FROM users', + dataSource: ds, + }) + + expect(res.sql).toContain('id') + expect(res.sql).toContain('name') + expect(res.sql).not.toContain('password_hash') + }) + + it('returns original SQL when dataSource is missing or non-internal', async () => { + const plugin = new SqlMacrosPlugin() + const externalDs = createSchemaMockDataSource(['id'], 'external') + + const noDsResult = await plugin.beforeQuery({ + sql: 'SELECT $_exclude(secret) FROM accounts', + }) + expect(noDsResult.sql).toBe('SELECT $_exclude(secret) FROM accounts') + + const extResult = await plugin.beforeQuery({ + sql: 'SELECT $_exclude(secret) FROM accounts', + dataSource: externalDs, + }) + expect(extResult.sql).toBe('SELECT $_exclude(secret) FROM accounts') + }) +}) From ed0b902fccdaafabeaeaf43300b959b05d7ca783 Mon Sep 17 00:00:00 2001 From: khuxaiphi-stack Date: Sat, 14 Nov 2026 02:14:09 +0100 Subject: [PATCH 6/6] test(stats): add Vitest unit tests for QueryStatsPlugin --- plugins/stats/index.test.ts | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 plugins/stats/index.test.ts diff --git a/plugins/stats/index.test.ts b/plugins/stats/index.test.ts new file mode 100644 index 0000000..dd84b21 --- /dev/null +++ b/plugins/stats/index.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { StatsPlugin } from './index' +import { ResendPlugin } from '../resend/index' + +describe('StatsPlugin & ResendPlugin', () => { + it('StatsPlugin rejects non-admin requests and returns statistics for admin', async () => { + let currentRole = 'user' + const mockDataSource = { + source: 'internal', + rpc: { + getStatistics: vi.fn(async () => ({ + rowsRead: 120, + rowsWritten: 15, + })), + }, + registry: { + currentPlugins: () => ['starbasedb:stats'], + }, + } + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('config', { role: currentRole }) + c.set('dataSource', mockDataSource) + await next() + }) + + const statsPlugin = new StatsPlugin() + await statsPlugin.register(app as any) + + const unauthRes = await app.request('/_internal/stats') + expect(unauthRes.status).toBe(400) + + currentRole = 'admin' + const adminRes = await app.request('/_internal/stats') + expect(adminRes.status).toBe(200) + const body: any = await adminRes.json() + expect(body.result.rowsRead).toBe(120) + expect(body.result.plugins).toEqual(['starbasedb:stats']) + }) + + it('ResendPlugin sends email via Resend API and handles errors', async () => { + const plugin = new ResendPlugin({ apiKey: 're_test_123' }) + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'email_001' }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Invalid API key' }), { + status: 401, + }) + ) + + const okRes = await plugin.sendEmail( + 'noreply@outerbase.com', + ['dev@outerbase.com'], + 'Test Subject', + '

Hello

' + ) + expect(okRes.id).toBe('email_001') + + await expect( + plugin.sendEmail( + 'noreply@outerbase.com', + ['dev@outerbase.com'], + 'Fail Subject', + '

Fail

' + ) + ).rejects.toThrow('Invalid API key') + + fetchSpy.mockRestore() + }) +})