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
85 changes: 85 additions & 0 deletions plugins/cron/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>()
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)
})
})
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, 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')
})
})
27 changes: 17 additions & 10 deletions plugins/sql-macros/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
}

Expand Down
76 changes: 76 additions & 0 deletions plugins/stats/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>()
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',
'<p>Hello</p>'
)
expect(okRes.id).toBe('email_001')

await expect(
plugin.sendEmail(
'noreply@outerbase.com',
['dev@outerbase.com'],
'Fail Subject',
'<p>Fail</p>'
)
).rejects.toThrow('Invalid API key')

fetchSpy.mockRestore()
})
})
111 changes: 111 additions & 0 deletions src/allowlist/index.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading