diff --git a/plugins/cron/index.test.ts b/plugins/cron/index.test.ts new file mode 100644 index 0000000..aadec43 --- /dev/null +++ b/plugins/cron/index.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { CronPlugin } from './index' +import { parseCronExpression, getNextExecutionTime } from './utils' +import type { DataSource } from '../../src/types' + +describe('Cron Utilities', () => { + it('should parse valid cron expression', () => { + const expr = parseCronExpression('*/5 * * * *') + expect(expr).toBeDefined() + }) + + it('should calculate next execution time in the future', () => { + const now = 1700000000000 + const nextTime = getNextExecutionTime('*/10 * * * *', now) + expect(nextTime).toBeGreaterThan(now) + }) +}) + +describe('CronPlugin', () => { + let plugin: CronPlugin + let mockRpc: any + let mockDataSource: DataSource + + beforeEach(() => { + vi.restoreAllMocks() + + mockRpc = { + executeQuery: vi.fn(), + setAlarm: vi.fn(), + deleteAlarm: vi.fn(), + } + + mockDataSource = { + source: 'internal', + internal: {} as any, + rpc: mockRpc, + } + + plugin = new CronPlugin() + }) + + it('should initialize with correct plugin name and auth requirement', () => { + expect(plugin.name).toBe('starbasedb:cron') + expect(plugin.opts.requiresAuth).toBe(true) + expect(plugin.pathPrefix).toBe('/cron') + }) + + it('should throw an error on addEvent if plugin is not initialized with dataSource', async () => { + await expect( + plugin.addEvent( + '*/5 * * * *', + 'sample_task', + {}, + 'http://localhost' + ) + ).rejects.toThrow('CronPlugin not properly initialized') + }) + + it('should register middleware and callback route on app', async () => { + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + + expect(mockApp.use).toHaveBeenCalledTimes(1) + expect(mockApp.post).toHaveBeenCalledWith( + '/cron/callback', + expect.any(Function) + ) + }) + + it('should invoke event callbacks when callback endpoint receives payloads', async () => { + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + + const callbackHandler = mockApp.post.mock.calls.find( + (call: any[]) => call[0] === '/cron/callback' + )[1] + + const eventCallback = vi.fn() + plugin.onEvent(eventCallback) + + const mockContext: any = { + req: { + json: vi.fn().mockResolvedValue([ + { + name: 'daily_sync', + cron_tab: '0 0 * * *', + payload: { action: 'sync' }, + }, + ]), + }, + } + + const res = await callbackHandler(mockContext) + expect(res.status).toBe(200) + expect(eventCallback).toHaveBeenCalledWith({ + name: 'daily_sync', + cron_tab: '0 0 * * *', + payload: { action: 'sync' }, + }) + }) + + it('should handle ctx.waitUntil when onEvent callback returns a Promise', async () => { + const mockCtx: any = { + waitUntil: vi.fn(), + } + + const asyncCallback = vi.fn().mockResolvedValue(true) + plugin.onEvent(asyncCallback, mockCtx) + + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + const callbackHandler = mockApp.post.mock.calls.find( + (call: any[]) => call[0] === '/cron/callback' + )[1] + + const mockContext: any = { + req: { + json: vi.fn().mockResolvedValue([ + { + name: 'backup', + cron_tab: '0 2 * * *', + payload: {}, + }, + ]), + }, + } + + await callbackHandler(mockContext) + expect(mockCtx.waitUntil).toHaveBeenCalledTimes(1) + }) +}) diff --git a/plugins/resend/index.test.ts b/plugins/resend/index.test.ts new file mode 100644 index 0000000..25ae2cb --- /dev/null +++ b/plugins/resend/index.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ResendPlugin } from './index' + +describe('ResendPlugin', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('should initialize with plugin name and API key', () => { + const plugin = new ResendPlugin({ apiKey: 're_test_key_123' }) + expect(plugin.name).toBe('starbasedb:resend') + expect(plugin.apiKey).toBe('re_test_key_123') + }) + + it('should successfully send an email via Resend API', async () => { + const plugin = new ResendPlugin({ apiKey: 're_valid_key' }) + const mockResponse = { id: 'email_abc123' } + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }) as any + + const result = await plugin.sendEmail( + 'onboarding@example.com', + ['user@example.com'], + 'Welcome!', + '
Hello World
' + ) + + expect(result).toEqual(mockResponse) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.resend.com/emails', + { + method: 'POST', + headers: { + Authorization: 'Bearer re_valid_key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: 'onboarding@example.com', + to: ['user@example.com'], + subject: 'Welcome!', + html: 'Hello World
', + }), + } + ) + }) + + it('should throw an error when Resend API returns non-ok response', async () => { + const plugin = new ResendPlugin({ apiKey: 're_invalid_key' }) + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ message: 'Invalid API Key' }), + }) as any + + await expect( + plugin.sendEmail( + 'test@example.com', + ['test@example.com'], + 'Test', + 'Test
' + ) + ).rejects.toThrow('Invalid API Key') + }) + + it('should fallback to default error message when response message is missing', async () => { + const plugin = new ResendPlugin() + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }) as any + + await expect( + plugin.sendEmail( + 'test@example.com', + ['test@example.com'], + 'Test', + 'Test
' + ) + ).rejects.toThrow('Failed to send email') + }) +}) diff --git a/plugins/stripe/index.test.ts b/plugins/stripe/index.test.ts new file mode 100644 index 0000000..98e8cef --- /dev/null +++ b/plugins/stripe/index.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StripeSubscriptionPlugin } from './index' + +describe('StripeSubscriptionPlugin', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('should throw an error if stripeSecretKey is missing', () => { + expect(() => { + new StripeSubscriptionPlugin({ + stripeSecretKey: '', + stripeWebhookSecret: 'whsec_test', + }) + }).toThrow('Stripe API key is required for this plugin.') + }) + + it('should initialize with correct name, keys and non-auth setting', () => { + const plugin = new StripeSubscriptionPlugin({ + stripeSecretKey: 'sk_test_123', + stripeWebhookSecret: 'whsec_123', + }) + + expect(plugin.name).toBe('starbasedb:stripe-subscriptions') + expect(plugin.opts.requiresAuth).toBe(false) + expect(plugin.pathPrefix).toBe('/stripe') + expect(plugin.stripeSecretKey).toBe('sk_test_123') + expect(plugin.stripeWebhookSecret).toBe('whsec_123') + }) + + it('should register routes and execute table creation in middleware', async () => { + const plugin = new StripeSubscriptionPlugin({ + stripeSecretKey: 'sk_test_123', + stripeWebhookSecret: 'whsec_123', + }) + + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + + expect(mockApp.use).toHaveBeenCalledTimes(1) + expect(mockApp.post).toHaveBeenCalledWith( + '/stripe/subscribe', + expect.any(Function) + ) + expect(mockApp.post).toHaveBeenCalledWith( + '/stripe/unsubscribe', + expect.any(Function) + ) + }) + + it('should reject subscribe requests missing required fields', async () => { + const plugin = new StripeSubscriptionPlugin({ + stripeSecretKey: 'sk_test_123', + stripeWebhookSecret: 'whsec_123', + }) + + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + + const subscribeHandler = mockApp.post.mock.calls.find( + (call: any[]) => call[0] === '/stripe/subscribe' + )[1] + + const mockContext: any = { + req: { + json: vi.fn().mockResolvedValue({ + userId: '', + stripeProductId: '', + }), + }, + } + + const response = await subscribeHandler(mockContext) + expect(response.status).toBe(400) + }) + + it('should reject unsubscribe requests missing userId', async () => { + const plugin = new StripeSubscriptionPlugin({ + stripeSecretKey: 'sk_test_123', + stripeWebhookSecret: 'whsec_123', + }) + + const mockApp: any = { + use: vi.fn(), + post: vi.fn(), + } + + await plugin.register(mockApp) + + const unsubscribeHandler = mockApp.post.mock.calls.find( + (call: any[]) => call[0] === '/stripe/unsubscribe' + )[1] + + const mockContext: any = { + req: { + json: vi.fn().mockResolvedValue({ + userId: '', + }), + }, + } + + const response = await unsubscribeHandler(mockContext) + expect(response.status).toBe(400) + }) +}) diff --git a/src/allowlist/index.test.ts b/src/allowlist/index.test.ts new file mode 100644 index 0000000..e7f03e5 --- /dev/null +++ b/src/allowlist/index.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isQueryAllowed } from './index' +import type { DataSource } from '../types' +import type { StarbaseDBConfiguration } from '../handler' + +let mockDataSource: DataSource +let mockConfig: StarbaseDBConfiguration + +beforeEach(() => { + vi.clearAllMocks() + + mockDataSource = { + source: 'external', + external: { dialect: 'sqlite' }, + rpc: { + executeQuery: vi.fn(), + }, + } as any + + mockConfig = { + outerbaseApiKey: 'mock-api-key', + role: 'user', + features: { allowlist: true, rls: true, rest: true }, + } +}) + +describe('Allowlist Module (isQueryAllowed)', () => { + it('should return true when allowlist is not enabled', async () => { + const result = await isQueryAllowed({ + sql: 'SELECT * FROM secret_table', + isEnabled: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBe(true) + expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('should return true when user role is admin regardless of allowlist', async () => { + const adminConfig: StarbaseDBConfiguration = { + ...mockConfig, + role: 'admin', + } + + const result = await isQueryAllowed({ + sql: 'DROP TABLE important_table', + isEnabled: true, + dataSource: mockDataSource, + config: adminConfig, + }) + + expect(result).toBe(true) + expect(mockDataSource.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('should return an Error if no SQL string is provided', async () => { + // Mock loadAllowlist query return + vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([]) + + const result = await isQueryAllowed({ + sql: '', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe( + 'No SQL provided for allowlist check' + ) + }) + + it('should allow queries matching the loaded allowlist AST', async () => { + vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([ + { + sql_statement: 'SELECT id, name FROM users WHERE id = 1', + source: 'external', + }, + ]) + + const result = await isQueryAllowed({ + sql: 'SELECT id, name FROM users WHERE id = 1;', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBe(true) + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(1) + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: 'SELECT sql_statement, source FROM tmp_allowlist_queries WHERE source="external"', + }) + }) + + it('should reject queries not in allowlist and record rejection audit log', async () => { + // 1st call: loadAllowlist + vi.mocked(mockDataSource.rpc.executeQuery) + .mockResolvedValueOnce([ + { + sql_statement: 'SELECT id FROM users', + source: 'external', + }, + ]) + // 2nd call: addRejectedQuery + .mockResolvedValueOnce([]) + + await expect( + isQueryAllowed({ + sql: 'DELETE FROM users WHERE id = 99', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('Query not allowed') + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(2) + expect(mockDataSource.rpc.executeQuery).toHaveBeenNthCalledWith(2, { + sql: 'INSERT INTO tmp_allowlist_rejections (sql_statement, source) VALUES (?, ?)', + params: ['DELETE FROM users WHERE id = 99', 'external'], + }) + }) + + it('should handle trailing semicolon normalization equivalence', async () => { + vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([ + { + sql_statement: 'SELECT * FROM products;', + source: 'external', + }, + ]) + + const result = await isQueryAllowed({ + sql: 'SELECT * FROM products', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(result).toBe(true) + }) + + it('should gracefully handle loadAllowlist failure by falling back to empty allowlist', async () => { + vi.mocked(mockDataSource.rpc.executeQuery) + .mockRejectedValueOnce(new Error('Connection failure')) + .mockResolvedValueOnce([]) + + await expect( + isQueryAllowed({ + sql: 'SELECT * FROM items', + isEnabled: true, + dataSource: mockDataSource, + config: mockConfig, + }) + ).rejects.toThrow('Query not allowed') + }) +}) diff --git a/src/import/csv.test.ts b/src/import/csv.test.ts new file mode 100644 index 0000000..f7ff227 --- /dev/null +++ b/src/import/csv.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { importTableFromCsvRoute } from './csv' +import { executeOperation } from '../export' +import { createResponse } from '../utils' +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: 'external', + external: { dialect: 'sqlite' }, + rpc: { executeQuery: vi.fn() }, + } as any + + mockConfig = { + outerbaseApiKey: 'mock-api-key', + role: 'admin', + features: { allowlist: true, rls: true, rest: true }, + } +}) + +describe('CSV Import Module', () => { + it('should return 400 for empty request body', async () => { + const request = new Request('http://localhost', { + method: 'POST', + body: null, + }) + + const response = await importTableFromCsvRoute( + 'users', + request, + mockDataSource, + mockConfig + ) + + expect(response.status).toBe(400) + const jsonResponse = (await response.json()) as { error?: string } + expect(jsonResponse.error).toBe('Request body is empty') + }) + + it('should return 400 for unsupported Content-Type', async () => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: '