diff --git a/plugins/clerk/index.test.ts b/plugins/clerk/index.test.ts new file mode 100644 index 0000000..8c7beb7 --- /dev/null +++ b/plugins/clerk/index.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ClerkPlugin } from './index' +import { Hono } from 'hono' +import type { DataSource } from '../../src/types' +import { Webhook } from 'svix' +import { jwtVerify, importSPKI } from 'jose' + +vi.mock('svix', () => { + return { + Webhook: vi.fn().mockImplementation(() => ({ + verify: vi.fn(), + })), + } +}) + +vi.mock('jose', () => { + return { + importSPKI: vi.fn().mockResolvedValue('mock-public-key'), + jwtVerify: vi.fn(), + } +}) + +describe('ClerkPlugin', () => { + let plugin: ClerkPlugin + let mockDataSource: DataSource + let mockVerify: any + + beforeEach(() => { + vi.clearAllMocks() + + mockDataSource = { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockResolvedValue([]), + } as any, + } + + mockVerify = vi.fn() + vi.mocked(Webhook).mockImplementation( + () => + ({ + verify: mockVerify, + }) as any + ) + + plugin = new ClerkPlugin({ + clerkSigningSecret: 'whsec_test_secret_12345', + clerkInstanceId: 'inst_123', + clerkSessionPublicKey: 'mock-spki-key', + verifySessions: true, + permittedOrigins: ['https://example.com'], + dataSource: mockDataSource, + }) + }) + + describe('Constructor & Configuration', () => { + it('should throw an error if clerkSigningSecret is missing', () => { + expect(() => { + new ClerkPlugin({ + clerkSigningSecret: '', + dataSource: mockDataSource, + }) + }).toThrow('A signing secret is required for this plugin.') + }) + + it('should initialize with correct properties and default pathPrefix', () => { + expect(plugin.name).toBe('starbasedb:clerk') + expect(plugin.opts.requiresAuth).toBe(false) + expect(plugin.pathPrefix).toBe('/clerk') + expect(plugin.clerkInstanceId).toBe('inst_123') + }) + }) + + describe('Middleware Registration', () => { + it('should create user and session tables on request', async () => { + const app = new Hono() + await plugin.register(app as any) + + app.get('/test', (c) => c.text('ok')) + const res = await app.request('http://localhost/test') + + expect(res.status).toBe(200) + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(2) + }) + + it('should skip session table creation when verifySessions is false', async () => { + const noSessionPlugin = new ClerkPlugin({ + clerkSigningSecret: 'whsec_test', + verifySessions: false, + dataSource: mockDataSource, + }) + + const app = new Hono() + await noSessionPlugin.register(app as any) + + app.get('/test', (c) => c.text('ok')) + await app.request('http://localhost/test') + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(1) + }) + }) + + describe('Webhook Endpoint (/clerk/webhook)', () => { + it('should return 400 if required svix headers are missing', async () => { + const app = new Hono() + await plugin.register(app as any) + + const res = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body: any = await res.json() + expect(body.error).toContain('Missing required headers') + }) + + it('should return 401 if instance ID does not match', async () => { + mockVerify.mockReturnValue({ + type: 'user.created', + instance_id: 'wrong_inst', + data: { id: 'u_1' }, + }) + + const app = new Hono() + await plugin.register(app as any) + + const res = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'svix-id': 'msg_123', + 'svix-signature': 'v1,sig', + 'svix-timestamp': '1234567890', + }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(401) + const body: any = await res.json() + expect(body.error).toBe('Invalid instance ID') + }) + + it('should process user.created event and upsert user', async () => { + mockVerify.mockReturnValue({ + type: 'user.created', + instance_id: 'inst_123', + data: { + id: 'user_abc', + first_name: 'John', + last_name: 'Doe', + primary_email_address_id: 'email_1', + email_addresses: [ + { id: 'email_1', email_address: 'john@example.com' }, + ], + }, + }) + + const app = new Hono() + await plugin.register(app as any) + + const res = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'svix-id': 'msg_1', + 'svix-signature': 'sig_1', + 'svix-timestamp': '12345', + }, + body: JSON.stringify({ test: true }), + }) + + expect(res.status).toBe(200) + const body: any = await res.json() + expect(body.result.success).toBe(true) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: expect.any(String), + params: ['user_abc', 'john@example.com', 'John', 'Doe'], + }) + }) + + it('should process user.deleted event', async () => { + mockVerify.mockReturnValue({ + type: 'user.deleted', + instance_id: 'inst_123', + data: { id: 'user_delete_1' }, + }) + + const app = new Hono() + await plugin.register(app as any) + + const res = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'svix-id': 'msg_1', + 'svix-signature': 'sig_1', + 'svix-timestamp': '12345', + }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(200) + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: expect.any(String), + params: ['user_delete_1'], + }) + }) + + it('should process session.created and session.ended events', async () => { + const app = new Hono() + await plugin.register(app as any) + + // session.created + mockVerify.mockReturnValueOnce({ + type: 'session.created', + instance_id: 'inst_123', + data: { id: 'sess_1', user_id: 'usr_1' }, + }) + + const res1 = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'svix-id': 'msg_1', + 'svix-signature': 'sig_1', + 'svix-timestamp': '12345', + }, + body: JSON.stringify({}), + }) + expect(res1.status).toBe(200) + + // session.ended + mockVerify.mockReturnValueOnce({ + type: 'session.ended', + instance_id: 'inst_123', + data: { id: 'sess_1', user_id: 'usr_1' }, + }) + + const res2 = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'svix-id': 'msg_2', + 'svix-signature': 'sig_2', + 'svix-timestamp': '12345', + }, + body: JSON.stringify({}), + }) + expect(res2.status).toBe(200) + }) + + it('should return 400 when svix verify throws an error', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid webhook signature') + }) + + const app = new Hono() + await plugin.register(app as any) + + const res = await app.request('http://localhost/clerk/webhook', { + method: 'POST', + headers: { + 'svix-id': 'msg_bad', + 'svix-signature': 'sig_bad', + 'svix-timestamp': '12345', + }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body: any = await res.json() + expect(body.error).toContain('Webhook processing failed') + }) + }) + + describe('Authentication (authenticate & sessionExistsInDb)', () => { + it('should return false if verifySessions is disabled or public key is missing', async () => { + const noKeyPlugin = new ClerkPlugin({ + clerkSigningSecret: 'whsec_test', + clerkSessionPublicKey: undefined, + dataSource: mockDataSource, + }) + + const result = await noKeyPlugin.authenticate({ + token: 'mock-token', + }) + expect(result).toBe(false) + }) + + it('should return false if neither cookie nor token is provided', async () => { + const result = await plugin.authenticate({ + cookie: null, + token: undefined, + }) + expect(result).toBe(false) + }) + + it('should return false if token is expired', async () => { + const now = Math.floor(Date.now() / 1000) + vi.mocked(jwtVerify).mockResolvedValueOnce({ + payload: { + sub: 'usr_1', + sid: 'sess_1', + exp: now - 3600, // expired 1 hour ago + }, + } as any) + + const result = await plugin.authenticate({ token: 'expired-token' }) + expect(result).toBe(false) + }) + + it('should return false if azp claim does not match permittedOrigins', async () => { + const now = Math.floor(Date.now() / 1000) + vi.mocked(jwtVerify).mockResolvedValueOnce({ + payload: { + sub: 'usr_1', + sid: 'sess_1', + exp: now + 3600, + azp: 'https://attacker.com', + }, + } as any) + + const result = await plugin.authenticate({ + token: 'unauthorized-origin-token', + }) + expect(result).toBe(false) + }) + + it('should return false if session does not exist in database', async () => { + const now = Math.floor(Date.now() / 1000) + vi.mocked(jwtVerify).mockResolvedValueOnce({ + payload: { + sub: 'usr_1', + sid: 'sess_1', + exp: now + 3600, + azp: 'https://example.com', + }, + } as any) + + vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([]) + + const result = await plugin.authenticate({ + token: 'valid-token-no-db-session', + }) + expect(result).toBe(false) + }) + + it('should return payload if authentication and session verification succeed', async () => { + const now = Math.floor(Date.now() / 1000) + const payload = { + sub: 'usr_1', + sid: 'sess_1', + exp: now + 3600, + azp: 'https://example.com', + } + + vi.mocked(jwtVerify).mockResolvedValueOnce({ payload } as any) + vi.mocked(mockDataSource.rpc.executeQuery).mockResolvedValueOnce([ + { id: 'sess_1' }, + ] as any) + + const result = await plugin.authenticate({ + cookie: '__session=valid_jwt_cookie_value', + }) + + expect(result).toEqual(payload) + }) + + it('should handle sessionExistsInDb catching database errors gracefully', async () => { + vi.mocked(mockDataSource.rpc.executeQuery).mockRejectedValueOnce( + new Error('Database connection failed') + ) + + const exists = await plugin.sessionExistsInDb({ + sub: 'usr_1', + sid: 'sess_1', + }) + expect(exists).toBe(false) + }) + }) +}) diff --git a/plugins/sql-macros/index.test.ts b/plugins/sql-macros/index.test.ts new file mode 100644 index 0000000..a618ca7 --- /dev/null +++ b/plugins/sql-macros/index.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SqlMacrosPlugin } from './index' +import { Hono } from 'hono' +import type { DataSource } from '../../src/types' +import type { StarbaseDBConfiguration } from '../../src/handler' + +describe('SqlMacrosPlugin', () => { + let plugin: SqlMacrosPlugin + let mockDataSource: DataSource + let mockConfig: StarbaseDBConfiguration + + beforeEach(() => { + vi.clearAllMocks() + + mockConfig = { + role: 'client', + outerbaseApiKey: 'mock-key', + } + + mockDataSource = { + source: 'internal', + rpc: { + executeQuery: vi + .fn() + .mockResolvedValue([ + { column_name: 'id' }, + { column_name: 'name' }, + { column_name: 'email' }, + { column_name: 'password' }, + ]), + } as any, + } + + plugin = new SqlMacrosPlugin({ preventSelectStar: true }) + }) + + describe('Initialization & Registration', () => { + it('should initialize with correct plugin name and options', () => { + expect(plugin.name).toBe('starbasedb:sql-macros') + expect(plugin.preventSelectStar).toBe(true) + }) + + it('should capture config from middleware', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('config', mockConfig) + await next() + }) + await plugin.register(app as any) + + app.get('/test', (c) => c.text('ok')) + await app.request('http://localhost/test') + + expect(plugin.config).toEqual(mockConfig) + }) + }) + + describe('preventSelectStar enforcement', () => { + it('should throw error when non-admin executes SELECT *', async () => { + plugin.config = { role: 'client' } + + await expect( + plugin.beforeQuery({ + sql: 'SELECT * FROM users;', + dataSource: mockDataSource, + config: plugin.config, + }) + ).rejects.toThrow( + 'SELECT * is not allowed. Please specify explicit columns.' + ) + }) + + it('should allow SELECT * when role is admin', async () => { + plugin.config = { role: 'admin' } + + const res = await plugin.beforeQuery({ + sql: 'SELECT * FROM users', + dataSource: mockDataSource, + config: plugin.config, + }) + + expect(res.sql).toBe('SELECT * FROM users') + }) + + it('should allow SELECT with explicit columns for non-admin', async () => { + plugin.config = { role: 'client' } + + const res = await plugin.beforeQuery({ + sql: 'SELECT id, name FROM users', + dataSource: mockDataSource, + config: plugin.config, + }) + + expect(res.sql).toBe('SELECT id, name FROM users') + }) + + it('should not throw for non-SELECT statements like INSERT or UPDATE', async () => { + plugin.config = { role: 'client' } + + const res = await plugin.beforeQuery({ + sql: "INSERT INTO users (id, name) VALUES (1, 'Alice')", + dataSource: mockDataSource, + config: plugin.config, + }) + + expect(res.sql).toContain('INSERT INTO users') + }) + + it('should return original SQL if preventSelectStar is disabled', async () => { + const lenientPlugin = new SqlMacrosPlugin({ + preventSelectStar: false, + }) + lenientPlugin.config = { role: 'client' } + + const res = await lenientPlugin.beforeQuery({ + sql: 'SELECT * FROM users', + dataSource: mockDataSource, + config: lenientPlugin.config, + }) + + expect(res.sql).toBe('SELECT * FROM users') + }) + }) + + describe('$_exclude / __exclude macro processing', () => { + it('should expand $_exclude to all columns except the excluded one', async () => { + plugin.config = { role: 'admin' } + + const res = await plugin.beforeQuery({ + sql: 'SELECT $_exclude(password) FROM users', + dataSource: mockDataSource, + config: plugin.config, + }) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledWith({ + sql: expect.stringContaining("pragma_table_info('users')"), + }) + expect(res.sql).toContain('`id`') + expect(res.sql).toContain('`name`') + expect(res.sql).toContain('`email`') + expect(res.sql).not.toContain('`password`') + }) + + it('should return original SQL if data source is external', async () => { + plugin.config = { role: 'admin' } + const externalDs: DataSource = { + source: 'external', + external: { provider: 'postgres' } as any, + rpc: { executeQuery: vi.fn() } as any, + } + + const res = await plugin.beforeQuery({ + sql: 'SELECT $_exclude(password) FROM users', + dataSource: externalDs, + config: plugin.config, + }) + + expect(res.sql).toBe('SELECT $_exclude(password) FROM users') + expect(externalDs.rpc.executeQuery).not.toHaveBeenCalled() + }) + + it('should return original SQL if query is a pragma_table_info query', async () => { + plugin.config = { role: 'admin' } + + const res = await plugin.beforeQuery({ + sql: "SELECT * FROM pragma_table_info('users')", + dataSource: mockDataSource, + config: plugin.config, + }) + + expect(res.sql).toContain('pragma_table_info') + }) + + it('should return untouched sql and params if dataSource is undefined', async () => { + const res = await plugin.beforeQuery({ + sql: 'SELECT id FROM users', + params: [1], + dataSource: undefined, + }) + + expect(res.sql).toBe('SELECT id FROM users') + expect(res.params).toEqual([1]) + }) + }) +}) diff --git a/plugins/stats/index.test.ts b/plugins/stats/index.test.ts new file mode 100644 index 0000000..7f76b9f --- /dev/null +++ b/plugins/stats/index.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StatsPlugin } from './index' +import { Hono } from 'hono' +import type { DataSource } from '../../src/types' +import type { StarbaseDBConfiguration } from '../../src/handler' + +describe('StatsPlugin', () => { + let plugin: StatsPlugin + let mockDataSource: DataSource + let mockConfig: StarbaseDBConfiguration + + beforeEach(() => { + vi.clearAllMocks() + + mockConfig = { + role: 'admin', + outerbaseApiKey: 'test-key', + } + + mockDataSource = { + source: 'internal', + rpc: { + getStatistics: vi.fn().mockResolvedValue({ + database_size_bytes: 40960, + page_count: 10, + page_size: 4096, + freelist_count: 0, + tables_count: 5, + }), + } as any, + registry: { + currentPlugins: vi.fn().mockReturnValue(['starbasedb:stats']), + } as any, + } + + plugin = new StatsPlugin() + }) + + it('should initialize with correct default name and options', () => { + expect(plugin.name).toBe('starbasedb:stats') + expect(plugin.opts.requiresAuth).toBe(true) + expect(plugin.prefix).toBe('/_internal/stats') + }) + + it('should register route and return statistics for admin users', async () => { + const app = new Hono() + + app.use('*', async (c, next) => { + c.set('config', mockConfig) + c.set('dataSource', mockDataSource) + await next() + }) + + await plugin.register(app as any) + + const res = await app.request('http://localhost/_internal/stats') + expect(res.status).toBe(200) + + const body: any = await res.json() + expect(body.result).toBeDefined() + expect(body.result.database_size_bytes).toBe(40960) + expect(body.result.plugins).toEqual(['starbasedb:stats']) + expect(mockDataSource.rpc.getStatistics).toHaveBeenCalledTimes(1) + }) + + it('should reject non-admin users with 400 Unauthorized request', async () => { + const app = new Hono() + + app.use('*', async (c, next) => { + c.set('config', { ...mockConfig, role: 'client' }) + c.set('dataSource', mockDataSource) + await next() + }) + + await plugin.register(app as any) + + const res = await app.request('http://localhost/_internal/stats') + expect(res.status).toBe(400) + const text = await res.text() + expect(text).toBe('Unauthorized request') + expect(mockDataSource.rpc.getStatistics).not.toHaveBeenCalled() + }) + + it('should reject requests when config is undefined', async () => { + const app = new Hono() + + app.use('*', async (c, next) => { + c.set('config', undefined) + c.set('dataSource', mockDataSource) + await next() + }) + + await plugin.register(app as any) + + const res = await app.request('http://localhost/_internal/stats') + expect(res.status).toBe(400) + expect(await res.text()).toBe('Unauthorized request') + }) +}) diff --git a/plugins/websocket/index.test.ts b/plugins/websocket/index.test.ts new file mode 100644 index 0000000..4fe67b7 --- /dev/null +++ b/plugins/websocket/index.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { WebSocketPlugin } from './index' +import { Hono } from 'hono' +import type { DataSource } from '../../src/types' +import type { StarbaseDBConfiguration } from '../../src/handler' + +declare global { + var WebSocket: any + var WebSocketPair: any + var Response: any +} + +const OriginalResponse = globalThis.Response + +class MockCloudflareResponse { + body: any + status: number + webSocket: any + constructor(body?: any, init?: any) { + this.body = body + this.status = init?.status ?? 200 + this.webSocket = init?.webSocket + } + text() { + return Promise.resolve(String(this.body ?? '')) + } +} + +describe('WebSocketPlugin', () => { + let plugin: WebSocketPlugin + let mockDataSource: DataSource + let mockConfig: StarbaseDBConfiguration + let mockExecuteQuery: any + let mockClientWs: any + let mockServerWs: any + + beforeEach(() => { + vi.clearAllMocks() + global.Response = MockCloudflareResponse as any + + mockExecuteQuery = vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]) + + mockConfig = { + role: 'client', + outerbaseApiKey: 'ws-key', + } + + mockDataSource = { + source: 'internal', + rpc: { + executeQuery: mockExecuteQuery, + } as any, + } + + mockClientWs = { + send: vi.fn(), + close: vi.fn(), + } + + mockServerWs = { + accept: vi.fn(), + send: vi.fn(), + addEventListener: vi.fn(), + } + + global.WebSocketPair = vi.fn().mockImplementation(() => ({ + 0: mockClientWs, + 1: mockServerWs, + })) + + plugin = new WebSocketPlugin({ prefix: '/custom-socket' }) + }) + + afterEach(() => { + global.Response = OriginalResponse + }) + + describe('Initialization & Registration', () => { + it('should initialize with custom prefix and default options', () => { + expect(plugin.name).toBe('starbasedb:websocket') + expect(plugin.opts.requiresAuth).toBe(true) + expect(plugin['prefix']).toBe('/custom-socket') + }) + + it('should default to /socket prefix when no option provided', () => { + const defaultPlugin = new WebSocketPlugin() + expect(defaultPlugin['prefix']).toBe('/socket') + }) + }) + + describe('HTTP to WebSocket Upgrade', () => { + it('should reject non-websocket upgrade requests with 400', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('config', mockConfig) + c.set('dataSource', mockDataSource) + c.set('operations', { executeQuery: mockExecuteQuery }) + await next() + }) + + await plugin.register(app as any) + + const res = await app.request('http://localhost/custom-socket', { + method: 'GET', + headers: {}, + }) + + expect(res.status).toBe(400) + expect(await res.text()).toBe('Expected upgrade request') + }) + + it('should upgrade request when upgrade header is websocket', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('config', mockConfig) + c.set('dataSource', mockDataSource) + c.set('operations', { executeQuery: mockExecuteQuery }) + await next() + }) + + await plugin.register(app as any) + + const res = await app.request('http://localhost/custom-socket', { + method: 'GET', + headers: { + upgrade: 'websocket', + }, + }) + + expect(res.status).toBe(101) + expect(mockServerWs.accept).toHaveBeenCalled() + expect(mockServerWs.addEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function) + ) + }) + }) + + describe('sendMessage helper', () => { + it('should invoke send on client WebSocket', () => { + plugin.sendMessage('hello from server', mockClientWs) + expect(mockClientWs.send).toHaveBeenCalledWith('hello from server') + }) + }) + + describe('Message event processing', () => { + it('should handle query action and execute query', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('config', mockConfig) + c.set('dataSource', mockDataSource) + c.set('operations', { executeQuery: mockExecuteQuery }) + await next() + }) + + await plugin.register(app as any) + + await app.request('http://localhost/custom-socket', { + method: 'GET', + headers: { + upgrade: 'websocket', + }, + }) + + const messageHandler = + mockServerWs.addEventListener.mock.calls.find( + (call: any[]) => call[0] === 'message' + )[1] + + await messageHandler({ + data: JSON.stringify({ + action: 'query', + sql: 'SELECT * FROM users', + params: [], + }), + }) + + expect(mockExecuteQuery).toHaveBeenCalledWith({ + sql: 'SELECT * FROM users', + params: [], + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + expect(mockServerWs.send).toHaveBeenCalledWith( + JSON.stringify([{ id: 1, name: 'Alice' }]) + ) + }) + }) +}) diff --git a/src/do.test.ts b/src/do.test.ts index 272c4e9..b2f9e0d 100644 --- a/src/do.test.ts +++ b/src/do.test.ts @@ -3,7 +3,14 @@ import { StarbaseDBDurableObject } from './do' vi.mock('cloudflare:workers', () => { return { - DurableObject: class MockDurableObject {}, + DurableObject: class MockDurableObject { + ctx: any + env: any + constructor(ctx: any, env: any) { + this.ctx = ctx + this.env = env + } + }, } }) @@ -56,10 +63,19 @@ global.Response = class { this.status = init?.status ?? 200 this.webSocket = init?.webSocket } + text() { + return Promise.resolve(String(this.body ?? '')) + } + json() { + return Promise.resolve( + typeof this.body === 'string' ? JSON.parse(this.body) : this.body + ) + } } const mockStorage = { sql: { + databaseSize: 16384, exec: vi.fn().mockReturnValue({ columnNames: ['id', 'name'], raw: vi.fn().mockReturnValue([ @@ -74,6 +90,9 @@ const mockStorage = { rowsWritten: 1, }), }, + getAlarm: vi.fn().mockResolvedValue(1700000000), + setAlarm: vi.fn().mockResolvedValue(undefined), + deleteAlarm: vi.fn().mockResolvedValue(undefined), } const mockDurableObjectState = { @@ -81,17 +100,33 @@ const mockDurableObjectState = { getTags: vi.fn().mockReturnValue(['session-123']), } as any -const mockEnv = {} as any +const mockEnv = { + CLIENT_AUTHORIZATION_TOKEN: 'test-auth-token-xyz', +} as any let instance: StarbaseDBDurableObject beforeEach(() => { + mockStorage.sql.exec.mockReturnValue({ + columnNames: ['id', 'name'], + raw: vi.fn().mockReturnValue([ + [1, 'Alice'], + [2, 'Bob'], + ]), + toArray: vi.fn().mockReturnValue([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]), + rowsRead: 2, + rowsWritten: 1, + }) + instance = new StarbaseDBDurableObject(mockDurableObjectState, mockEnv) vi.clearAllMocks() }) describe('StarbaseDBDurableObject Tests', () => { - it('should initialize SQL storage', () => { + it('should initialize SQL storage and install tmp tables', () => { expect(instance.sql).toBeDefined() expect(instance.storage).toBeDefined() }) @@ -107,6 +142,24 @@ describe('StarbaseDBDurableObject Tests', () => { ]) }) + it('should execute a query with parameters and raw formatting', async () => { + const sql = 'SELECT * FROM users WHERE id = ?' + const result: any = await instance.executeQuery({ + sql, + params: [1], + isRaw: true, + }) + + expect(mockStorage.sql.exec).toHaveBeenCalledWith(sql, 1) + expect(result.columns).toEqual(['id', 'name']) + expect(result.rows).toEqual([ + [1, 'Alice'], + [2, 'Bob'], + ]) + expect(result.meta.rows_read).toBe(2) + expect(result.meta.rows_written).toBe(1) + }) + it('should execute a transaction and return results', async () => { const queries = [ { sql: 'SELECT * FROM orders' }, @@ -118,11 +171,11 @@ describe('StarbaseDBDurableObject Tests', () => { expect(result.length).toBe(2) }) - it('should handle WebSocket connections', async () => { - const response = await instance.clientConnected('session-123') + it('should handle WebSocket connections and generate UUID if sessionId not provided', async () => { + const response = await instance.clientConnected() expect(response.status).toBe(101) - expect(instance.connections.has('session-123')).toBe(true) + expect(instance.connections.size).toBe(1) }) it('should return 400 for unknown fetch requests', async () => { @@ -133,9 +186,7 @@ describe('StarbaseDBDurableObject Tests', () => { }) it('should handle errors in executeQuery', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) // ✅ Suppress error logs + vi.spyOn(console, 'error').mockImplementation(() => {}) mockStorage.sql.exec.mockImplementationOnce(() => { throw new Error('Query failed') @@ -145,4 +196,204 @@ describe('StarbaseDBDurableObject Tests', () => { instance.executeQuery({ sql: 'INVALID QUERY' }) ).rejects.toThrow('Query failed') }) + + describe('Alarms API & Processing', () => { + it('should get, set and delete alarms via storage', async () => { + const alarmTime = await instance.getAlarm() + expect(alarmTime).toBe(1700000000) + expect(mockStorage.getAlarm).toHaveBeenCalled() + + const futureTime = Date.now() + 100000 + await instance.setAlarm(futureTime) + expect(mockStorage.setAlarm).toHaveBeenCalledWith( + futureTime, + undefined + ) + + await instance.deleteAlarm() + expect(mockStorage.deleteAlarm).toHaveBeenCalled() + }) + + it('should exit alarm early if no active tmp_cron_tasks exist', async () => { + mockStorage.sql.exec.mockReturnValueOnce({ + toArray: vi.fn().mockReturnValue([]), + }) + + const globalFetch = vi.fn() + global.fetch = globalFetch + + await instance.alarm() + + expect(globalFetch).not.toHaveBeenCalled() + }) + + it('should dispatch cron webhook and handle successful execution', async () => { + mockStorage.sql.exec.mockReturnValueOnce({ + toArray: vi.fn().mockReturnValue([ + { + id: 1, + callback_host: 'https://example.com', + is_active: 1, + }, + ]), + }) + + const globalFetch = vi.fn().mockResolvedValue(new Response('ok')) + global.fetch = globalFetch + + await instance.alarm() + + expect(globalFetch).toHaveBeenCalledWith( + 'https://example.com/cron/callback', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-auth-token-xyz', + }), + }) + ) + }) + + it('should reschedule recovery alarm if cron callback fetch fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + mockStorage.sql.exec.mockReturnValueOnce({ + toArray: vi.fn().mockReturnValue([ + { + id: 1, + callback_host: 'https://example.com', + is_active: 1, + }, + ]), + }) + + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await instance.alarm() + + expect(mockStorage.setAlarm).toHaveBeenCalledWith( + expect.any(Number), + undefined + ) + }) + }) + + describe('getStatistics()', () => { + it('should compute database statistics from sql storage and query log', async () => { + mockStorage.sql.exec.mockReturnValueOnce({ + toArray: vi.fn().mockReturnValue([{ count: 42 }]), + }) + + const stats = await instance.getStatistics() + + expect(stats.databaseSize).toBe(16384) + expect(stats.activeConnections).toBe(0) + expect(stats.recentQueries).toBe(42) + }) + + it('should return recentQueries as 0 if tmp_query_log is empty', async () => { + mockStorage.sql.exec.mockReturnValueOnce({ + toArray: vi.fn().mockReturnValue([]), + }) + + const stats = await instance.getStatistics() + expect(stats.recentQueries).toBe(0) + }) + }) + + describe('WebSocket messaging & closing', () => { + it('should process webSocketMessage with query action', async () => { + const mockWs = { send: vi.fn() } as any + + await instance.webSocketMessage( + mockWs, + JSON.stringify({ + action: 'query', + sql: 'SELECT 1', + params: [], + }) + ) + + expect(mockWs.send).toHaveBeenCalled() + }) + + it('should close WebSocket and remove tag from connections map', async () => { + const mockWs = { close: vi.fn() } as any + instance.connections.set('session-123', mockWs) + + await instance.webSocketClose(mockWs, 1000, 'Normal Closure', true) + + expect(mockWs.close).toHaveBeenCalledWith( + 1000, + 'StarbaseDB is closing WebSocket connection' + ) + expect(instance.connections.has('session-123')).toBe(false) + }) + }) + + describe('fetch() routing', () => { + it('should return 400 for /socket when upgrade header is missing', async () => { + const req = new Request('https://example.com/socket') + const res = await instance.fetch(req) + expect(res.status).toBe(400) + expect(await res.text()).toBe('Expected WebSocket') + }) + + it('should upgrade /socket when upgrade header is websocket', async () => { + const req = new Request( + 'https://example.com/socket?sessionId=sess-1', + { + headers: { upgrade: 'websocket' }, + } + ) + const res = await instance.fetch(req) + expect(res.status).toBe(101) + expect(instance.connections.has('sess-1')).toBe(true) + }) + + it('should broadcast message to all connected clients', async () => { + const ws1 = { send: vi.fn() } as any + const ws2 = { send: vi.fn() } as any + instance.connections.set('conn-1', ws1) + instance.connections.set('conn-2', ws2) + + const req = new Request('https://example.com/socket/broadcast', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'ping' }), + }) + + const res = await instance.fetch(req) + expect(res.status).toBe(200) + expect(ws1.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'ping' }) + ) + expect(ws2.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'ping' }) + ) + }) + + it('should broadcast message to specific sessionId only when provided', async () => { + const ws1 = { send: vi.fn() } as any + const ws2 = { send: vi.fn() } as any + instance.connections.set('conn-1', ws1) + instance.connections.set('conn-2', ws2) + + const req = new Request( + 'https://example.com/socket/broadcast?sessionId=conn-1', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'direct' }), + } + ) + + const res = await instance.fetch(req) + expect(res.status).toBe(200) + expect(ws1.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'direct' }) + ) + expect(ws2.send).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/operation.extended.test.ts b/src/operation.extended.test.ts new file mode 100644 index 0000000..7b89b37 --- /dev/null +++ b/src/operation.extended.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + executeQuery, + executeTransaction, + executeExternalQuery, +} from './operation' +import type { DataSource } from './types' +import type { StarbaseDBConfiguration } from './handler' + +describe('operation.ts - Extended Coverage', () => { + let mockDataSource: DataSource + let mockConfig: StarbaseDBConfiguration + + beforeEach(() => { + vi.clearAllMocks() + + mockConfig = { + role: 'client', + outerbaseApiKey: 'token-outerbase-123', + features: { allowlist: false, rls: false, rest: true }, + } + + mockDataSource = { + source: 'internal', + rpc: { + executeQuery: vi.fn().mockResolvedValue([ + { id: 10, name: 'Alice' }, + { id: 20, name: 'Bob' }, + ]), + executeTransaction: vi.fn(), + } as any, + } + }) + + describe('executeExternalQuery', () => { + it('should throw an error if dataSource.external is missing', async () => { + const dsNoExternal: DataSource = { + source: 'external', + rpc: {} as any, + } + + await expect( + executeExternalQuery({ + sql: 'SELECT 1', + params: [], + dataSource: dsNoExternal, + config: mockConfig, + }) + ).rejects.toThrow('External connection not found.') + }) + + it('should call Outerbase raw API and convert ? params to :paramX format', async () => { + const dsExternal: DataSource = { + source: 'external', + external: { + dialect: 'postgresql', + provider: 'postgresql', + host: 'localhost', + port: 5432, + user: 'user', + password: 'pw', + database: 'db', + } as any, + rpc: {} as any, + } + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + response: { + results: { + items: [{ id: 1, name: 'Outerbase-Item' }], + }, + }, + }), + }) + global.fetch = mockFetch + + const result = await executeExternalQuery({ + sql: 'SELECT * FROM users WHERE id = ? AND status = ?', + params: [123, 'active'], + dataSource: dsExternal, + config: mockConfig, + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://app.outerbase.com/api/v1/ezql/raw', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-Source-Token': 'token-outerbase-123', + }), + body: JSON.stringify({ + query: 'SELECT * FROM users WHERE id = :param0 AND status = :param1', + params: { param0: 123, param1: 'active' }, + }), + }) + ) + + expect(result).toEqual([{ id: 1, name: 'Outerbase-Item' }]) + }) + + it('should return empty array when Outerbase raw API response is malformed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const dsExternal: DataSource = { + source: 'external', + external: { provider: 'postgres' } as any, + rpc: {} as any, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ + message: 'API Token revoked', + }), + }) + + const result = await executeExternalQuery({ + sql: 'SELECT 1', + params: [], + dataSource: dsExternal, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + }) + + describe('Plugin registry beforeQuery & afterQuery lifecycle', () => { + it('should pass through beforeQuery and afterQuery with registry modifications', async () => { + const registry = { + beforeQuery: vi.fn().mockResolvedValue({ + sql: 'SELECT * FROM users WHERE active = 1', + params: [999], + }), + afterQuery: vi.fn().mockImplementation(async ({ result }) => { + return result.map((r: any) => ({ ...r, decorated: true })) + }), + } + + const dsWithRegistry: DataSource = { + source: 'internal', + registry: registry as any, + rpc: { + executeQuery: vi + .fn() + .mockResolvedValue([{ id: 1, name: 'Test' }]), + } as any, + } + + const result = await executeQuery({ + sql: 'SELECT * FROM users', + params: [], + isRaw: false, + dataSource: dsWithRegistry, + config: mockConfig, + }) + + expect(registry.beforeQuery).toHaveBeenCalled() + expect(dsWithRegistry.rpc.executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sql: 'SELECT * FROM users WHERE active = 1', + params: [999], + }) + ) + expect(registry.afterQuery).toHaveBeenCalled() + expect(result).toEqual([{ id: 1, name: 'Test', decorated: true }]) + }) + + it('should gracefully catch and ignore errors inside afterQuery registry plugin', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const registry = { + beforeQuery: vi + .fn() + .mockImplementation(async ({ sql, params }) => ({ + sql, + params, + })), + afterQuery: vi + .fn() + .mockRejectedValue(new Error('Plugin hook failed')), + } + + const dsWithFailingRegistry: DataSource = { + source: 'internal', + registry: registry as any, + rpc: { + executeQuery: vi.fn().mockResolvedValue([{ id: 1 }]), + } as any, + } + + const result = await executeQuery({ + sql: 'SELECT 1', + params: [], + isRaw: false, + dataSource: dsWithFailingRegistry, + config: mockConfig, + }) + + expect(result).toEqual([{ id: 1 }]) + }) + }) + + describe('executeTransaction', () => { + it('should return empty array if dataSource is not provided', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await executeTransaction({ + queries: [{ sql: 'SELECT 1' }], + isRaw: false, + dataSource: undefined as any, + config: mockConfig, + }) + + expect(result).toEqual([]) + }) + + it('should execute each transaction query sequentially on data source via executeQuery', async () => { + const queries = [ + { sql: 'INSERT INTO users VALUES (1, "A")' }, + { sql: 'INSERT INTO users VALUES (2, "B")' }, + ] + + const result = await executeTransaction({ + queries, + isRaw: false, + dataSource: mockDataSource, + config: mockConfig, + }) + + expect(mockDataSource.rpc.executeQuery).toHaveBeenCalledTimes(2) + expect(result).toHaveLength(2) + }) + }) +})