feat: migrate to docker-compose with selfhosted-supabase-mcp

- Replace legacy directory structure (api/, db/, functions/, logs/, pooler/) with
  single docker-compose.yml based self-hosted setup
- Add selfhosted-supabase-mcp TypeScript MCP server for database management
- Add .dockerignore for Docker build context
- Update .gitignore to exclude .env files, volumes/, backups, logs
This commit is contained in:
2026-02-21 19:32:57 +00:00
parent a7cca50f17
commit 95af17c02d
107 changed files with 12575 additions and 3295 deletions
@@ -0,0 +1,443 @@
/**
* Tests for SelfhostedSupabaseClient
*
* These tests verify the core client functionality including:
* - Client initialization and validation
* - SQL execution via RPC
* - SQL execution via direct pg connection
* - Transaction handling
* - Getter methods
*/
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test';
import { SelfhostedSupabaseClient } from '../../client/index.js';
import type { SelfhostedSupabaseClientOptions } from '../../types/index.js';
// Mock the external dependencies
const mockSupabaseClient = {
rpc: mock(() => Promise.resolve({ data: [], error: null })),
};
const mockCreateClient = mock(() => mockSupabaseClient);
// Mock @supabase/supabase-js
mock.module('@supabase/supabase-js', () => ({
createClient: mockCreateClient,
}));
// Mock pg Pool
const mockPoolClient = {
query: mock(() => Promise.resolve({ rows: [] })),
release: mock(() => {}),
};
const mockPool = {
connect: mock(() => Promise.resolve(mockPoolClient)),
end: mock(() => Promise.resolve()),
on: mock(() => {}),
};
const mockPoolConstructor = mock(() => mockPool);
mock.module('pg', () => ({
Pool: mockPoolConstructor,
}));
describe('SelfhostedSupabaseClient', () => {
const validOptions: SelfhostedSupabaseClientOptions = {
supabaseUrl: 'https://test.supabase.co',
supabaseAnonKey: 'test-anon-key',
supabaseServiceRoleKey: 'test-service-role-key',
databaseUrl: 'postgresql://postgres:postgres@localhost:5432/postgres',
jwtSecret: 'test-jwt-secret',
};
beforeEach(() => {
// Reset all mocks
mockCreateClient.mockClear();
mockSupabaseClient.rpc.mockClear();
mockPool.connect.mockClear();
mockPool.end.mockClear();
mockPoolClient.query.mockClear();
mockPoolClient.release.mockClear();
// Reset to default successful behavior
mockSupabaseClient.rpc.mockImplementation(() =>
Promise.resolve({ data: [], error: null })
);
mockPoolClient.query.mockImplementation(() =>
Promise.resolve({ rows: [] })
);
});
describe('create() factory method', () => {
test('creates client with valid options', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client).toBeDefined();
expect(mockCreateClient).toHaveBeenCalledWith(
validOptions.supabaseUrl,
validOptions.supabaseAnonKey,
undefined
);
});
test('throws error when supabaseUrl is missing', async () => {
const invalidOptions = {
...validOptions,
supabaseUrl: '',
};
await expect(SelfhostedSupabaseClient.create(invalidOptions)).rejects.toThrow();
});
test('throws error when supabaseAnonKey is missing', async () => {
const invalidOptions = {
...validOptions,
supabaseAnonKey: '',
};
await expect(SelfhostedSupabaseClient.create(invalidOptions)).rejects.toThrow();
});
});
describe('getters', () => {
test('getSupabaseUrl returns configured URL', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.getSupabaseUrl()).toBe(validOptions.supabaseUrl);
});
test('getAnonKey returns configured anon key', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.getAnonKey()).toBe(validOptions.supabaseAnonKey);
});
test('getServiceRoleKey returns configured service role key', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.getServiceRoleKey()).toBe(validOptions.supabaseServiceRoleKey);
});
test('getServiceRoleKey returns undefined when not configured', async () => {
const optionsWithoutServiceKey = {
supabaseUrl: validOptions.supabaseUrl,
supabaseAnonKey: validOptions.supabaseAnonKey,
};
const client = await SelfhostedSupabaseClient.create(optionsWithoutServiceKey);
expect(client.getServiceRoleKey()).toBeUndefined();
});
test('getJwtSecret returns configured JWT secret', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.getJwtSecret()).toBe(validOptions.jwtSecret);
});
test('getDbUrl returns configured database URL', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.getDbUrl()).toBe(validOptions.databaseUrl);
});
test('isPgAvailable returns true when databaseUrl is configured', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.isPgAvailable()).toBe(true);
});
test('isPgAvailable returns false when databaseUrl is not configured', async () => {
const optionsWithoutDb = {
supabaseUrl: validOptions.supabaseUrl,
supabaseAnonKey: validOptions.supabaseAnonKey,
};
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
expect(client.isPgAvailable()).toBe(false);
});
});
describe('executeSqlViaRpc', () => {
test('returns success response for valid query', async () => {
const expectedData = [{ id: 1, name: 'test' }];
mockSupabaseClient.rpc.mockImplementation(() =>
Promise.resolve({ data: expectedData, error: null })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlViaRpc('SELECT * FROM users');
expect(result).toEqual(expectedData);
expect(mockSupabaseClient.rpc).toHaveBeenCalledWith('execute_sql', {
query: 'SELECT * FROM users',
read_only: false,
});
});
test('passes read_only parameter correctly', async () => {
mockSupabaseClient.rpc.mockImplementation(() =>
Promise.resolve({ data: [], error: null })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
await client.executeSqlViaRpc('SELECT 1', true);
expect(mockSupabaseClient.rpc).toHaveBeenCalledWith('execute_sql', {
query: 'SELECT 1',
read_only: true,
});
});
test('returns error response when RPC fails', async () => {
// First call succeeds (initialization check), second call fails
let callCount = 0;
mockSupabaseClient.rpc.mockImplementation(() => {
callCount++;
if (callCount === 1) {
// Initialization check succeeds
return Promise.resolve({ data: [], error: null });
}
// Actual query fails
return Promise.resolve({
data: null,
error: {
message: 'Query failed',
code: 'P0001',
details: 'Some details',
hint: 'Try something else',
},
});
});
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlViaRpc('INVALID SQL');
expect(result).toHaveProperty('error');
expect((result as { error: { message: string } }).error.message).toBe('Query failed');
expect((result as { error: { code: string } }).error.code).toBe('P0001');
});
test('returns error when RPC function does not exist', async () => {
// First call during initialization - function doesn't exist
mockSupabaseClient.rpc.mockImplementation(() =>
Promise.resolve({
data: null,
error: { message: 'Function not found', code: '42883' },
})
);
const client = await SelfhostedSupabaseClient.create({
...validOptions,
supabaseServiceRoleKey: undefined,
databaseUrl: undefined,
});
const result = await client.executeSqlViaRpc('SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { message: string } }).error.message).toContain(
'execute_sql RPC function not found'
);
});
test('handles unexpected response format', async () => {
// First call succeeds (initialization), second returns bad format
let callCount = 0;
mockSupabaseClient.rpc.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: [], error: null });
}
return Promise.resolve({ data: 'not an array', error: null });
});
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlViaRpc('SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { code: string } }).error.code).toBe('MCP_RPC_FORMAT_ERROR');
});
test('handles RPC exceptions during query', async () => {
// First call succeeds (initialization), second throws
let callCount = 0;
mockSupabaseClient.rpc.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: [], error: null });
}
return Promise.reject(new Error('Network error'));
});
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlViaRpc('SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { code: string } }).error.code).toBe('MCP_RPC_EXCEPTION');
expect((result as { error: { message: string } }).error.message).toContain('Network error');
});
});
describe('executeSqlWithPg', () => {
test('returns success response for valid query', async () => {
const expectedRows = [{ id: 1, name: 'test' }];
mockPoolClient.query.mockImplementation(() =>
Promise.resolve({ rows: expectedRows })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlWithPg('SELECT * FROM users');
expect(result).toEqual(expectedRows);
});
test('returns error when databaseUrl is not configured', async () => {
const optionsWithoutDb = {
supabaseUrl: validOptions.supabaseUrl,
supabaseAnonKey: validOptions.supabaseAnonKey,
};
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
const result = await client.executeSqlWithPg('SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { message: string } }).error.message).toContain(
'DATABASE_URL is not configured'
);
});
test('handles database errors', async () => {
const dbError = new Error('Connection refused') as Error & { code: string };
dbError.code = 'ECONNREFUSED';
mockPoolClient.query.mockImplementation(() => Promise.reject(dbError));
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeSqlWithPg('SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { message: string } }).error.message).toContain(
'Connection refused'
);
expect((result as { error: { code: string } }).error.code).toBe('ECONNREFUSED');
});
test('releases client after successful query', async () => {
mockPoolClient.query.mockImplementation(() =>
Promise.resolve({ rows: [] })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
await client.executeSqlWithPg('SELECT 1');
expect(mockPoolClient.release).toHaveBeenCalled();
});
test('releases client after failed query', async () => {
mockPoolClient.query.mockImplementation(() =>
Promise.reject(new Error('Query failed'))
);
const client = await SelfhostedSupabaseClient.create(validOptions);
await client.executeSqlWithPg('SELECT 1');
expect(mockPoolClient.release).toHaveBeenCalled();
});
});
describe('executeTransactionWithPg', () => {
test('commits transaction on success', async () => {
const expectedResult = { success: true };
mockPoolClient.query.mockImplementation(() =>
Promise.resolve({ rows: [] })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
const result = await client.executeTransactionWithPg(async (pgClient) => {
await pgClient.query('INSERT INTO users (name) VALUES ($1)', ['test']);
return expectedResult;
});
expect(result).toEqual(expectedResult);
// Check that BEGIN was called
expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN');
// Check that COMMIT was called
expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
});
test('rolls back transaction on failure', async () => {
let beginCalled = false;
mockPoolClient.query.mockImplementation((query: string) => {
if (query === 'BEGIN') {
beginCalled = true;
return Promise.resolve({ rows: [] });
}
if (query === 'ROLLBACK') {
return Promise.resolve({ rows: [] });
}
if (query === 'COMMIT') {
return Promise.resolve({ rows: [] });
}
// Fail on the actual operation
return Promise.reject(new Error('Insert failed'));
});
const client = await SelfhostedSupabaseClient.create(validOptions);
await expect(
client.executeTransactionWithPg(async (pgClient) => {
await pgClient.query('INSERT INTO users (name) VALUES ($1)', ['test']);
})
).rejects.toThrow('Insert failed');
expect(beginCalled).toBe(true);
expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
});
test('throws error when databaseUrl is not configured', async () => {
const optionsWithoutDb = {
supabaseUrl: validOptions.supabaseUrl,
supabaseAnonKey: validOptions.supabaseAnonKey,
};
const client = await SelfhostedSupabaseClient.create(optionsWithoutDb);
await expect(
client.executeTransactionWithPg(async () => {})
).rejects.toThrow('DATABASE_URL is not configured');
});
test('releases client after transaction', async () => {
mockPoolClient.query.mockImplementation(() =>
Promise.resolve({ rows: [] })
);
const client = await SelfhostedSupabaseClient.create(validOptions);
await client.executeTransactionWithPg(async () => {});
expect(mockPoolClient.release).toHaveBeenCalled();
});
test('releases client after failed transaction', async () => {
mockPoolClient.query.mockImplementation((query: string) => {
if (query === 'BEGIN' || query === 'ROLLBACK') {
return Promise.resolve({ rows: [] });
}
return Promise.reject(new Error('Failed'));
});
const client = await SelfhostedSupabaseClient.create(validOptions);
try {
await client.executeTransactionWithPg(async (pgClient) => {
await pgClient.query('FAIL');
});
} catch {
// Expected to throw
}
expect(mockPoolClient.release).toHaveBeenCalled();
});
});
describe('supabase client access', () => {
test('exposes supabase client instance', async () => {
const client = await SelfhostedSupabaseClient.create(validOptions);
expect(client.supabase).toBeDefined();
expect(client.supabase).toBe(mockSupabaseClient);
});
});
});
@@ -0,0 +1,265 @@
/**
* Shared test mocks and helpers for the selfhosted-supabase-mcp test suite.
*/
import { mock } from 'bun:test';
import type { SelfhostedSupabaseClient } from '../../client/index.js';
import type { ToolContext } from '../../tools/types.js';
import type { SqlExecutionResult, SqlSuccessResponse, SqlErrorResponse } from '../../types/index.js';
/**
* Options for creating a mock SelfhostedSupabaseClient
*/
export interface MockClientOptions {
pgAvailable?: boolean;
serviceRoleAvailable?: boolean;
rpcResult?: SqlExecutionResult;
pgResult?: SqlExecutionResult;
serviceRoleRpcResult?: SqlExecutionResult;
supabaseUrl?: string;
anonKey?: string;
serviceRoleKey?: string;
jwtSecret?: string;
dbUrl?: string;
supabaseClient?: MockSupabaseClient;
}
/**
* Mock Supabase client type for auth operations
*/
export interface MockSupabaseClient {
auth: {
admin: {
listUsers: ReturnType<typeof mock>;
getUserById: ReturnType<typeof mock>;
createUser: ReturnType<typeof mock>;
updateUserById: ReturnType<typeof mock>;
deleteUser: ReturnType<typeof mock>;
};
};
rpc: ReturnType<typeof mock>;
}
/**
* Creates a mock Supabase client with configurable auth admin methods
*/
export function createMockSupabaseClient(overrides?: Partial<MockSupabaseClient>): MockSupabaseClient {
return {
auth: {
admin: {
listUsers: mock(() => Promise.resolve({ data: { users: [] }, error: null })),
getUserById: mock(() => Promise.resolve({ data: { user: null }, error: null })),
createUser: mock(() => Promise.resolve({ data: { user: null }, error: null })),
updateUserById: mock(() => Promise.resolve({ data: { user: null }, error: null })),
deleteUser: mock(() => Promise.resolve({ data: null, error: null })),
...overrides?.auth?.admin,
},
},
rpc: mock(() => Promise.resolve({ data: [], error: null })),
...overrides,
};
}
/**
* Creates a mock SelfhostedSupabaseClient for testing tools
*/
export function createMockClient(options: MockClientOptions = {}): SelfhostedSupabaseClient {
const {
pgAvailable = true,
serviceRoleAvailable = true,
rpcResult = [] as SqlSuccessResponse,
pgResult = [] as SqlSuccessResponse,
serviceRoleRpcResult = [] as SqlSuccessResponse,
supabaseUrl = 'https://test.supabase.co',
anonKey = 'test-anon-key',
serviceRoleKey = 'test-service-role-key',
jwtSecret = 'test-jwt-secret',
dbUrl = 'postgresql://test:test@localhost:5432/test',
supabaseClient = createMockSupabaseClient(),
} = options;
// Create a mock that satisfies the SelfhostedSupabaseClient interface
const mockClient = {
supabase: supabaseClient,
executeSqlViaRpc: mock(async (_query: string, _readOnly?: boolean) => rpcResult),
executeSqlWithPg: mock(async (_query: string) => pgResult),
executeSqlViaServiceRoleRpc: mock(async (_query: string, _readOnly?: boolean) => serviceRoleRpcResult),
executeTransactionWithPg: mock(async <T>(callback: (client: unknown) => Promise<T>) => {
const mockPgClient = {
query: mock(() => Promise.resolve({ rows: [] })),
};
return callback(mockPgClient);
}),
isPgAvailable: () => pgAvailable,
isServiceRoleAvailable: () => serviceRoleAvailable,
getSupabaseUrl: () => supabaseUrl,
getAnonKey: () => anonKey,
getServiceRoleKey: () => (serviceRoleKey ? serviceRoleKey : undefined),
getJwtSecret: () => (jwtSecret ? jwtSecret : undefined),
getDbUrl: () => (pgAvailable ? dbUrl : undefined),
} as unknown as SelfhostedSupabaseClient;
return mockClient;
}
/**
* Creates a mock ToolContext for testing tool execute functions
*/
export function createMockContext(client?: SelfhostedSupabaseClient): ToolContext {
return {
selfhostedClient: client ?? createMockClient(),
log: mock((_message: string, _level?: 'info' | 'warn' | 'error') => {}),
workspacePath: '/test/workspace',
};
}
/**
* Creates a SQL success response
*/
export function createSuccessResponse(rows: Record<string, unknown>[]): SqlSuccessResponse {
return rows;
}
/**
* Creates a SQL error response
*/
export function createErrorResponse(
message: string,
code?: string,
details?: string,
hint?: string
): SqlErrorResponse {
return {
error: {
message,
code,
details,
hint,
},
};
}
/**
* Sample test data for various entity types
*/
export const testData = {
users: [
{
id: '550e8400-e29b-41d4-a716-446655440001',
email: '[email protected]',
role: 'authenticated',
created_at: '2024-01-01T00:00:00Z',
last_sign_in_at: '2024-01-15T12:00:00Z',
raw_app_meta_data: { provider: 'email' },
raw_user_meta_data: { name: 'Test User 1' },
},
{
id: '550e8400-e29b-41d4-a716-446655440002',
email: '[email protected]',
role: 'authenticated',
created_at: '2024-01-02T00:00:00Z',
last_sign_in_at: null,
raw_app_meta_data: {},
raw_user_meta_data: {},
},
],
tables: [
{
table_schema: 'public',
table_name: 'users',
table_type: 'BASE TABLE',
is_insertable_into: 'YES',
},
{
table_schema: 'public',
table_name: 'posts',
table_type: 'BASE TABLE',
is_insertable_into: 'YES',
},
],
extensions: [
{ name: 'plpgsql', installed_version: '1.0', comment: 'PL/pgSQL procedural language' },
{ name: 'uuid-ossp', installed_version: '1.1', comment: 'generate universally unique identifiers' },
],
buckets: [
{
id: 'bucket-1',
name: 'avatars',
owner: null,
public: true,
avif_autodetection: false,
file_size_limit: 5242880,
allowed_mime_types: ['image/png', 'image/jpeg'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
],
storageObjects: [
{
id: '550e8400-e29b-41d4-a716-446655440003',
name: 'avatar.png',
bucket_id: 'avatars',
owner: '550e8400-e29b-41d4-a716-446655440001',
version: null,
mimetype: 'image/png',
size: 1024,
metadata: { mimetype: 'image/png', size: 1024 },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_accessed_at: null,
},
],
migrations: [
{
version: '20240101000000',
name: 'initial_schema',
executed_at: '2024-01-01T00:00:00Z',
},
],
connections: [
{
pid: 12345,
usename: 'postgres',
datname: 'postgres',
client_addr: '127.0.0.1',
state: 'active',
query: 'SELECT 1',
backend_start: '2024-01-01T00:00:00Z',
},
],
};
/**
* Helper to create Express-like request/response mocks for middleware testing
*/
export function createMockExpressReqRes() {
const req = {
headers: {} as Record<string, string>,
user: undefined as unknown,
};
const res = {
statusCode: 200,
jsonBody: null as unknown,
status: mock(function(this: typeof res, code: number) {
this.statusCode = code;
return this;
}),
json: mock(function(this: typeof res, body: unknown) {
this.jsonBody = body;
return this;
}),
};
const next = mock(() => {});
return { req, res, next };
}
@@ -0,0 +1,211 @@
/**
* Integration tests for SelfhostedSupabaseClient
*
* These tests run against a real Supabase instance and are skipped
* when environment variables are not configured.
*
* Required environment variables:
* - SUPABASE_URL
* - SUPABASE_ANON_KEY
* - DATABASE_URL (optional, for direct pg connection tests)
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { SelfhostedSupabaseClient } from '../../client/index.js';
// Check if we have the required credentials
const hasCredentials = !!(
process.env.SUPABASE_URL &&
process.env.SUPABASE_ANON_KEY
);
const hasDatabaseUrl = !!process.env.DATABASE_URL;
// Skip all tests if credentials are not available
describe.skipIf(!hasCredentials)('SelfhostedSupabaseClient Integration Tests', () => {
let client: SelfhostedSupabaseClient;
beforeAll(async () => {
client = await SelfhostedSupabaseClient.create({
supabaseUrl: process.env.SUPABASE_URL!,
supabaseAnonKey: process.env.SUPABASE_ANON_KEY!,
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
});
});
describe('Client initialization', () => {
test('creates client successfully', () => {
expect(client).toBeDefined();
expect(client.supabase).toBeDefined();
});
test('getSupabaseUrl returns correct URL', () => {
expect(client.getSupabaseUrl()).toBe(process.env.SUPABASE_URL);
});
test('getAnonKey returns correct key', () => {
expect(client.getAnonKey()).toBe(process.env.SUPABASE_ANON_KEY);
});
test('isPgAvailable reflects DATABASE_URL configuration', () => {
expect(client.isPgAvailable()).toBe(hasDatabaseUrl);
});
});
describe('SQL execution via RPC', () => {
test('executes simple SELECT query', async () => {
const result = await client.executeSqlViaRpc('SELECT 1 as value', true);
// If RPC is not available, we'll get an error
if ('error' in result) {
console.log('RPC not available:', result.error.message);
// This is acceptable in integration tests - RPC may not be set up
expect(result.error).toBeDefined();
} else {
expect(Array.isArray(result)).toBe(true);
expect(result[0]?.value).toBe(1);
}
});
test('executes query returning multiple rows', async () => {
const result = await client.executeSqlViaRpc(
'SELECT generate_series(1, 3) as num',
true
);
if ('error' in result) {
console.log('RPC not available:', result.error.message);
expect(result.error).toBeDefined();
} else {
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(3);
}
});
});
describe.skipIf(!hasDatabaseUrl)('SQL execution via direct pg', () => {
test('executes simple SELECT query', async () => {
const result = await client.executeSqlWithPg('SELECT 1 as value');
if ('error' in result) {
console.log('Direct pg error:', result.error.message);
throw new Error(result.error.message);
}
expect(Array.isArray(result)).toBe(true);
expect(result[0]?.value).toBe(1);
});
test('executes query with multiple columns', async () => {
const result = await client.executeSqlWithPg(
"SELECT 'hello' as greeting, 42 as answer"
);
if ('error' in result) {
throw new Error(result.error.message);
}
expect(result[0]?.greeting).toBe('hello');
expect(result[0]?.answer).toBe(42);
});
test('handles query with no results', async () => {
const result = await client.executeSqlWithPg(
'SELECT 1 WHERE false'
);
if ('error' in result) {
throw new Error(result.error.message);
}
expect(result).toEqual([]);
});
test('returns error for invalid SQL', async () => {
const result = await client.executeSqlWithPg('INVALID SQL QUERY');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.error.message).toBeDefined();
}
});
});
describe.skipIf(!hasDatabaseUrl)('Transaction handling', () => {
test('commits transaction on success', async () => {
const testTableName = `test_integration_${Date.now()}`;
try {
// Create a test table in a transaction
await client.executeTransactionWithPg(async (pgClient) => {
await pgClient.query(`
CREATE TEMP TABLE ${testTableName} (id serial, name text)
`);
await pgClient.query(
`INSERT INTO ${testTableName} (name) VALUES ($1)`,
['test-value']
);
});
// Verify the table was created (temp tables are session-scoped)
// This test mainly verifies the transaction didn't throw
expect(true).toBe(true);
} catch (error) {
// If this fails, it's likely a permissions issue
console.log('Transaction test failed:', error);
expect(error).toBeDefined();
}
});
test('rolls back transaction on error', async () => {
try {
await client.executeTransactionWithPg(async (pgClient) => {
await pgClient.query('SELECT 1');
throw new Error('Intentional error for rollback test');
});
// Should not reach here
expect(true).toBe(false);
} catch (error) {
expect((error as Error).message).toContain('Intentional error');
}
});
});
describe('System catalog queries', () => {
test.skipIf(!hasDatabaseUrl)('lists database extensions', async () => {
const result = await client.executeSqlWithPg(`
SELECT extname as name
FROM pg_extension
LIMIT 5
`);
if ('error' in result) {
throw new Error(result.error.message);
}
expect(Array.isArray(result)).toBe(true);
// plpgsql is always installed
const hasPlpgsql = result.some((ext: { name: string }) => ext.name === 'plpgsql');
expect(hasPlpgsql).toBe(true);
});
test.skipIf(!hasDatabaseUrl)('queries pg_stat_activity', async () => {
const result = await client.executeSqlWithPg(`
SELECT pid, state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
LIMIT 5
`);
if ('error' in result) {
// May fail due to permissions
console.log('pg_stat_activity query failed:', result.error.message);
expect(result.error).toBeDefined();
} else {
expect(Array.isArray(result)).toBe(true);
}
});
});
});
@@ -0,0 +1,212 @@
/**
* Integration tests for MCP tools
*
* These tests run against a real Supabase instance and are skipped
* when environment variables are not configured.
*
* Required environment variables:
* - SUPABASE_URL
* - SUPABASE_ANON_KEY
* - DATABASE_URL (required for most tools)
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { SelfhostedSupabaseClient } from '../../client/index.js';
import type { ToolContext } from '../../tools/types.js';
// Import tools to test
import { listTablesTool } from '../../tools/list_tables.js';
import { listExtensionsTool } from '../../tools/list_extensions.js';
import { getDatabaseConnectionsTool } from '../../tools/get_database_connections.js';
import { executeSqlTool } from '../../tools/execute_sql.js';
import { getProjectUrlTool } from '../../tools/get_project_url.js';
import { verifyJwtSecretTool } from '../../tools/verify_jwt_secret.js';
import { listStorageBucketsTool } from '../../tools/list_storage_buckets.js';
// Check if we have the required credentials
const hasCredentials = !!(
process.env.SUPABASE_URL &&
process.env.SUPABASE_ANON_KEY
);
const hasDatabaseUrl = !!process.env.DATABASE_URL;
// Skip all tests if credentials are not available
describe.skipIf(!hasCredentials)('Tools Integration Tests', () => {
let client: SelfhostedSupabaseClient;
let context: ToolContext;
beforeAll(async () => {
client = await SelfhostedSupabaseClient.create({
supabaseUrl: process.env.SUPABASE_URL!,
supabaseAnonKey: process.env.SUPABASE_ANON_KEY!,
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
});
context = {
selfhostedClient: client,
log: (message: string, level?: 'info' | 'warn' | 'error') => {
console.log(`[${level || 'info'}] ${message}`);
},
};
});
describe('Simple getter tools', () => {
test('get_project_url returns configured URL', async () => {
const result = await getProjectUrlTool.execute({}, context);
expect(result.project_url).toBe(process.env.SUPABASE_URL);
});
test('verify_jwt_secret returns status', async () => {
const result = await verifyJwtSecretTool.execute({}, context);
if (process.env.JWT_SECRET) {
expect(result.jwt_secret_status).toBe('found');
} else {
expect(result.jwt_secret_status).toBe('not_configured');
}
});
});
describe.skipIf(!hasDatabaseUrl)('Database tools', () => {
test('list_tables returns table list', async () => {
const result = await listTablesTool.execute({}, context);
expect(Array.isArray(result)).toBe(true);
// All tables should have schema and name
result.forEach((table: { schema: string; name: string }) => {
expect(typeof table.schema).toBe('string');
expect(typeof table.name).toBe('string');
});
});
test('list_extensions returns extension list', async () => {
const result = await listExtensionsTool.execute({}, context);
expect(Array.isArray(result)).toBe(true);
// Each extension should have name and version
result.forEach((ext: { name: string; version: string }) => {
expect(typeof ext.name).toBe('string');
expect(typeof ext.version).toBe('string');
});
});
test('get_database_connections returns connection list', async () => {
try {
const result = await getDatabaseConnectionsTool.execute({}, context);
expect(Array.isArray(result)).toBe(true);
// Should have at least one connection (ourselves)
expect(result.length).toBeGreaterThan(0);
// Each connection should have pid
result.forEach((conn: { pid: number }) => {
expect(typeof conn.pid).toBe('number');
});
} catch (error) {
// May fail due to permissions on pg_stat_activity
console.log('get_database_connections failed (may be permissions):', error);
expect(error).toBeDefined();
}
});
test('execute_sql runs simple queries', async () => {
const result = await executeSqlTool.execute(
{ sql: 'SELECT 1 as value', read_only: true },
context
);
expect(Array.isArray(result)).toBe(true);
expect(result[0]?.value).toBe(1);
});
test('execute_sql handles complex queries', async () => {
const result = await executeSqlTool.execute(
{
sql: `
SELECT
'test' as name,
42 as number,
ARRAY[1,2,3] as arr,
'{"key": "value"}'::jsonb as json_data
`,
read_only: true,
},
context
);
expect(Array.isArray(result)).toBe(true);
expect(result[0]?.name).toBe('test');
expect(result[0]?.number).toBe(42);
});
test('execute_sql returns error for invalid SQL', async () => {
await expect(
executeSqlTool.execute(
{ sql: 'INVALID SQL STATEMENT' },
context
)
).rejects.toThrow('SQL Error');
});
});
describe.skipIf(!hasDatabaseUrl)('Storage tools', () => {
test('list_storage_buckets returns bucket list', async () => {
try {
const result = await listStorageBucketsTool.execute({}, context);
expect(Array.isArray(result)).toBe(true);
// Each bucket should have id and name
result.forEach((bucket: { id: string; name: string }) => {
expect(typeof bucket.id).toBe('string');
expect(typeof bucket.name).toBe('string');
});
} catch (error) {
// Storage schema may not exist
console.log('list_storage_buckets failed (storage may not be configured):', error);
expect(error).toBeDefined();
}
});
});
describe.skipIf(!hasDatabaseUrl)('Auth tools (read-only)', () => {
// Note: We only test read operations to avoid modifying data
// Create/Update/Delete tests would need proper cleanup
test('can query auth.users table structure', async () => {
// Just verify we can query the auth schema
try {
const result = await executeSqlTool.execute(
{
sql: `
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'auth' AND table_name = 'users'
LIMIT 5
`,
read_only: true,
},
context
);
expect(Array.isArray(result)).toBe(true);
// Should have some columns
expect(result.length).toBeGreaterThan(0);
} catch (error) {
// May not have access to auth schema
console.log('Auth schema query failed (may be permissions):', error);
expect(error).toBeDefined();
}
});
});
});
// Separate describe block for cleanup to ensure it runs
describe.skipIf(!hasCredentials)('Integration Test Cleanup', () => {
test('placeholder for cleanup', () => {
// Any test data cleanup would go here
// For now, we're using read-only operations
expect(true).toBe(true);
});
});
@@ -0,0 +1,294 @@
/**
* Tests for JWT Authentication Middleware
*
* These tests verify the JWT authentication middleware for HTTP transport mode:
* - Missing/invalid authorization headers
* - Token validation (signature, expiration, claims)
* - User info extraction
* - Error responses
*/
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import jwt from 'jsonwebtoken';
import { createAuthMiddleware, type AuthenticatedRequest } from '../../server/auth-middleware.js';
import type { Response, NextFunction } from 'express';
describe('createAuthMiddleware', () => {
// codacy:disable-line:hardcoded-credentials -- Test fixture, not a real secret
// nosec: hardcoded test credential for unit testing only
const JWT_SECRET = 'test-jwt-secret-key-for-testing'; // NOSONAR
const middleware = createAuthMiddleware(JWT_SECRET);
// Helper to create mock request/response/next
function createMocks() {
const req = {
headers: {} as Record<string, string>,
user: undefined,
} as AuthenticatedRequest;
const res = {
statusCode: 200,
body: null as unknown,
status: mock(function (this: typeof res, code: number) {
this.statusCode = code;
return this;
}),
json: mock(function (this: typeof res, body: unknown) {
this.body = body;
return this;
}),
} as unknown as Response;
const next = mock(() => {}) as NextFunction;
return { req, res, next };
}
// Helper to create valid JWT tokens
function createToken(payload: Record<string, unknown>, options?: jwt.SignOptions) {
return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256', ...options });
}
describe('Authorization header validation', () => {
test('returns 401 when Authorization header is missing', () => {
const { req, res, next } = createMocks();
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Missing Authorization header',
});
expect(next).not.toHaveBeenCalled();
});
test('returns 401 when Authorization header does not start with Bearer', () => {
const { req, res, next } = createMocks();
req.headers.authorization = 'Basic dXNlcjpwYXNz';
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Invalid Authorization header format. Expected: Bearer [token]',
});
expect(next).not.toHaveBeenCalled();
});
test('returns 401 when token is empty after Bearer prefix', () => {
const { req, res, next } = createMocks();
req.headers.authorization = 'Bearer ';
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Missing token in Authorization header',
});
expect(next).not.toHaveBeenCalled();
});
});
describe('Token signature validation', () => {
test('returns 401 for token with invalid signature', () => {
const { req, res, next } = createMocks();
// Create token with wrong secret
// codacy:disable-line:hardcoded-credentials -- Test fixture for signature mismatch
const invalidToken = jwt.sign({ sub: 'user-123' }, 'wrong-secret', { // NOSONAR
algorithm: 'HS256',
});
req.headers.authorization = `Bearer ${invalidToken}`;
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect((res as { body: { error: string } }).body.error).toBe('Unauthorized');
expect((res as { body: { message: string } }).body.message).toContain('Invalid token');
expect(next).not.toHaveBeenCalled();
});
test('returns 401 for malformed token', () => {
const { req, res, next } = createMocks();
req.headers.authorization = 'Bearer not.a.valid.jwt.token';
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect((res as { body: { error: string } }).body.error).toBe('Unauthorized');
expect(next).not.toHaveBeenCalled();
});
});
describe('Token expiration validation', () => {
test('returns 401 for expired token', () => {
const { req, res, next } = createMocks();
// Create token that expired 1 hour ago
const expiredToken = createToken(
{ sub: 'user-123' },
{ expiresIn: '-1h' }
);
req.headers.authorization = `Bearer ${expiredToken}`;
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect((res as { body: { message: string } }).body.message).toContain('expired');
expect(next).not.toHaveBeenCalled();
});
test('accepts token that has not expired', () => {
const { req, res, next } = createMocks();
const validToken = createToken(
{ sub: 'user-123' },
{ expiresIn: '1h' }
);
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('Token claims validation', () => {
test('returns 401 when sub claim is missing', () => {
const { req, res, next } = createMocks();
// Create token without sub claim
const tokenWithoutSub = createToken({ email: '[email protected]' });
req.headers.authorization = `Bearer ${tokenWithoutSub}`;
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect((res as { body: { message: string } }).body.message).toContain('missing subject');
expect(next).not.toHaveBeenCalled();
});
});
describe('Successful authentication', () => {
test('calls next() for valid token', () => {
const { req, res, next } = createMocks();
const validToken = createToken({ sub: 'user-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
test('sets req.user with userId from sub claim', () => {
const { req, res, next } = createMocks();
const validToken = createToken({ sub: 'user-abc-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user).toBeDefined();
expect(req.user?.userId).toBe('user-abc-123');
});
test('sets req.user.email from token', () => {
const { req, res, next } = createMocks();
const validToken = createToken(
{ sub: 'user-123', email: '[email protected]' },
{ expiresIn: '1h' }
);
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user?.email).toBe('[email protected]');
});
test('sets req.user.email to null when not in token', () => {
const { req, res, next } = createMocks();
const validToken = createToken({ sub: 'user-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user?.email).toBeNull();
});
test('sets req.user.role from token', () => {
const { req, res, next } = createMocks();
const validToken = createToken(
{ sub: 'user-123', role: 'admin' },
{ expiresIn: '1h' }
);
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user?.role).toBe('admin');
});
test('defaults req.user.role to authenticated when not in token', () => {
const { req, res, next } = createMocks();
const validToken = createToken({ sub: 'user-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user?.role).toBe('authenticated');
});
test('sets req.user.exp from token', () => {
const { req, res, next } = createMocks();
const validToken = createToken({ sub: 'user-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${validToken}`;
middleware(req, res, next);
expect(req.user?.exp).toBeGreaterThan(0);
// Should expire in about 1 hour
const oneHourFromNow = Math.floor(Date.now() / 1000) + 3600;
expect(req.user?.exp).toBeGreaterThan(oneHourFromNow - 60); // Allow 60s tolerance
expect(req.user?.exp).toBeLessThan(oneHourFromNow + 60);
});
test('extracts all fields from complete Supabase-style token', () => {
const { req, res, next } = createMocks();
const supabaseToken = createToken(
{
sub: 'uuid-user-id',
email: '[email protected]',
role: 'authenticated',
aud: 'authenticated',
iat: Math.floor(Date.now() / 1000),
},
{ expiresIn: '1h' }
);
req.headers.authorization = `Bearer ${supabaseToken}`;
middleware(req, res, next);
expect(req.user).toEqual({
userId: 'uuid-user-id',
email: '[email protected]',
role: 'authenticated',
exp: expect.any(Number),
});
});
});
describe('Different JWT secrets', () => {
test('middleware with different secret rejects tokens from another secret', () => {
const anotherMiddleware = createAuthMiddleware('different-secret');
const { req, res, next } = createMocks();
const token = createToken({ sub: 'user-123' }, { expiresIn: '1h' });
req.headers.authorization = `Bearer ${token}`;
anotherMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,8 @@
/**
* Test setup file for Bun test runner.
* This file is preloaded before all tests run.
*/
// Global test setup - mock environment variables
process.env.SUPABASE_URL = 'http://localhost:54321';
process.env.SUPABASE_ANON_KEY = 'test-anon-key';
@@ -0,0 +1,637 @@
/**
* Tests for authentication-related tools
*
* Tools tested:
* - list_auth_users
* - get_auth_user
* - create_auth_user
* - update_auth_user
* - delete_auth_user
*/
import { describe, test, expect, mock } from 'bun:test';
import { listAuthUsersTool } from '../../tools/list_auth_users.js';
import { createAuthUserTool } from '../../tools/create_auth_user.js';
import { deleteAuthUserTool } from '../../tools/delete_auth_user.js';
import { updateAuthUserTool } from '../../tools/update_auth_user.js';
import {
createMockClient,
createMockContext,
createSuccessResponse,
createErrorResponse,
testData,
} from '../helpers/mocks.js';
describe('listAuthUsersTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(listAuthUsersTool.name).toBe('list_auth_users');
});
test('has description', () => {
expect(listAuthUsersTool.description).toContain('user');
});
test('has input and output schemas', () => {
expect(listAuthUsersTool.inputSchema).toBeDefined();
expect(listAuthUsersTool.outputSchema).toBeDefined();
});
});
describe('input validation', () => {
test('accepts empty input with defaults', () => {
const result = listAuthUsersTool.inputSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
test('accepts custom limit and offset', () => {
const result = listAuthUsersTool.inputSchema.safeParse({ limit: 10, offset: 20 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(10);
expect(result.data.offset).toBe(20);
}
});
test('rejects negative limit', () => {
const result = listAuthUsersTool.inputSchema.safeParse({ limit: -1 });
expect(result.success).toBe(false);
});
test('rejects negative offset', () => {
const result = listAuthUsersTool.inputSchema.safeParse({ offset: -1 });
expect(result.success).toBe(false);
});
});
describe('execute', () => {
test('returns list of users', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(testData.users),
});
const context = createMockContext(mockClient);
const result = await listAuthUsersTool.execute({}, context);
expect(result).toEqual(testData.users);
});
test('returns empty array when no users', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await listAuthUsersTool.execute({}, context);
expect(result).toEqual([]);
});
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(listAuthUsersTool.execute({}, context)).rejects.toThrow(
'Direct database connection'
);
});
test('throws error on SQL failure', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('permission denied for table users', '42501'),
});
const context = createMockContext(mockClient);
await expect(listAuthUsersTool.execute({}, context)).rejects.toThrow('SQL Error');
});
test('uses pg connection directly (not RPC)', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
await listAuthUsersTool.execute({}, context);
expect(mockClient.executeSqlWithPg).toHaveBeenCalled();
expect(mockClient.executeSqlViaRpc).not.toHaveBeenCalled();
});
});
describe('output validation', () => {
test('validates correct user structure', () => {
const result = listAuthUsersTool.outputSchema.safeParse(testData.users);
expect(result.success).toBe(true);
});
test('rejects invalid UUID for id', () => {
const invalidUser = [{ ...testData.users[0], id: 'not-a-uuid' }];
const result = listAuthUsersTool.outputSchema.safeParse(invalidUser);
expect(result.success).toBe(false);
});
test('accepts null values for nullable fields', () => {
const userWithNulls = [{
id: '123e4567-e89b-12d3-a456-426614174000',
email: null,
role: null,
created_at: null,
last_sign_in_at: null,
raw_app_meta_data: null,
raw_user_meta_data: null,
}];
const result = listAuthUsersTool.outputSchema.safeParse(userWithNulls);
expect(result.success).toBe(true);
});
});
});
describe('createAuthUserTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(createAuthUserTool.name).toBe('create_auth_user');
});
test('has warning in description', () => {
expect(createAuthUserTool.description).toContain('WARNING');
});
});
describe('input validation', () => {
test('requires email', () => {
const result = createAuthUserTool.inputSchema.safeParse({ password: 'password123' });
expect(result.success).toBe(false);
});
test('requires password', () => {
const result = createAuthUserTool.inputSchema.safeParse({ email: '[email protected]' });
expect(result.success).toBe(false);
});
test('validates email format', () => {
const result = createAuthUserTool.inputSchema.safeParse({
email: 'not-an-email',
password: 'password123',
});
expect(result.success).toBe(false);
});
test('requires minimum password length', () => {
const result = createAuthUserTool.inputSchema.safeParse({
email: '[email protected]',
password: '12345', // 5 chars, needs 6
});
expect(result.success).toBe(false);
});
test('accepts valid input', () => {
const result = createAuthUserTool.inputSchema.safeParse({
email: '[email protected]',
password: 'password123',
});
expect(result.success).toBe(true);
});
test('accepts optional role and metadata', () => {
const result = createAuthUserTool.inputSchema.safeParse({
email: '[email protected]',
password: 'password123',
role: 'admin',
app_metadata: { custom: 'data' },
user_metadata: { name: 'Test User' },
});
expect(result.success).toBe(true);
});
});
describe('execute', () => {
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(
createAuthUserTool.execute(
{ email: '[email protected]', password: 'password123' },
context
)
).rejects.toThrow('Direct database connection');
});
test('creates user via transaction', async () => {
const createdUser = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
role: 'authenticated',
created_at: '2024-01-01T00:00:00Z',
last_sign_in_at: null,
raw_app_meta_data: {},
raw_user_meta_data: {},
};
const mockPgClient = {
query: mock(async (sql: string, _params?: unknown[]) => {
// The crypt test SELECT query doesn't have INSERT
if (sql.includes('crypt') && sql.includes('SELECT') && !sql.includes('INSERT')) {
return { rows: [{ crypt: 'test' }] };
}
// The INSERT query that creates the user
return { rows: [createdUser] };
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await createAuthUserTool.execute(
{ email: '[email protected]', password: 'password123' },
context
);
expect(result).toEqual(createdUser);
});
test('throws error when pgcrypto is not available', async () => {
const mockPgClient = {
query: mock(async (sql: string) => {
if (sql.includes('crypt')) {
throw new Error('function crypt does not exist');
}
return { rows: [] };
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await expect(
createAuthUserTool.execute(
{ email: '[email protected]', password: 'password123' },
context
)
).rejects.toThrow('pgcrypto');
});
test('handles unique violation error for duplicate email', async () => {
const mockPgClient = {
query: mock(async (sql: string) => {
if (sql.includes('crypt') && !sql.includes('INSERT')) {
return { rows: [{ crypt: 'test' }] };
}
const error = new Error('duplicate key value violates unique constraint');
(error as unknown as { code: string }).code = '23505';
throw error;
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await expect(
createAuthUserTool.execute(
{ email: '[email protected]', password: 'password123' },
context
)
).rejects.toThrow('already exists');
});
});
});
describe('deleteAuthUserTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(deleteAuthUserTool.name).toBe('delete_auth_user');
});
test('has description', () => {
expect(deleteAuthUserTool.description).toContain('Delete');
});
});
describe('input validation', () => {
test('requires user_id', () => {
const result = deleteAuthUserTool.inputSchema.safeParse({});
expect(result.success).toBe(false);
});
test('validates user_id is UUID', () => {
const result = deleteAuthUserTool.inputSchema.safeParse({ user_id: 'not-a-uuid' });
expect(result.success).toBe(false);
});
test('accepts valid UUID', () => {
const result = deleteAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result.success).toBe(true);
});
});
describe('execute', () => {
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(
deleteAuthUserTool.execute(
{ user_id: '123e4567-e89b-12d3-a456-426614174000' },
context
)
).rejects.toThrow('Direct database connection');
});
test('returns success when user is deleted', async () => {
const mockPgClient = {
query: mock(async () => ({ rowCount: 1 })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await deleteAuthUserTool.execute(
{ user_id: '123e4567-e89b-12d3-a456-426614174000' },
context
);
expect(result.success).toBe(true);
expect(result.message).toContain('Successfully deleted');
});
test('returns failure when user is not found', async () => {
const mockPgClient = {
query: mock(async () => ({ rowCount: 0 })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await deleteAuthUserTool.execute(
{ user_id: '123e4567-e89b-12d3-a456-426614174000' },
context
);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
test('throws error on database failure', async () => {
const mockPgClient = {
query: mock(async () => {
throw new Error('Database error');
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await expect(
deleteAuthUserTool.execute(
{ user_id: '123e4567-e89b-12d3-a456-426614174000' },
context
)
).rejects.toThrow('Failed to delete user');
});
});
describe('output validation', () => {
test('validates success response', () => {
const result = deleteAuthUserTool.outputSchema.safeParse({
success: true,
message: 'User deleted',
});
expect(result.success).toBe(true);
});
test('validates failure response', () => {
const result = deleteAuthUserTool.outputSchema.safeParse({
success: false,
message: 'User not found',
});
expect(result.success).toBe(true);
});
});
});
describe('updateAuthUserTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(updateAuthUserTool.name).toBe('update_auth_user');
});
test('has warning in description', () => {
expect(updateAuthUserTool.description).toContain('WARNING');
});
});
describe('input validation', () => {
test('requires user_id', () => {
const result = updateAuthUserTool.inputSchema.safeParse({ email: '[email protected]' });
expect(result.success).toBe(false);
});
test('validates user_id is UUID', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: 'not-a-uuid',
email: '[email protected]',
});
expect(result.success).toBe(false);
});
test('requires at least one field to update', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
});
expect(result.success).toBe(false);
});
test('accepts email update', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
});
expect(result.success).toBe(true);
});
test('accepts password update', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
password: 'newpassword123',
});
expect(result.success).toBe(true);
});
test('accepts role update', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
role: 'admin',
});
expect(result.success).toBe(true);
});
test('accepts metadata updates', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
user_metadata: { name: 'New Name' },
});
expect(result.success).toBe(true);
});
test('validates minimum password length', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
password: '12345',
});
expect(result.success).toBe(false);
});
test('validates email format', () => {
const result = updateAuthUserTool.inputSchema.safeParse({
user_id: '123e4567-e89b-12d3-a456-426614174000',
email: 'not-an-email',
});
expect(result.success).toBe(false);
});
});
describe('execute', () => {
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(
updateAuthUserTool.execute(
{
user_id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
},
context
)
).rejects.toThrow('Direct database connection');
});
test('updates user via transaction', async () => {
const updatedUser = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
role: 'authenticated',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
last_sign_in_at: null,
raw_app_meta_data: {},
raw_user_meta_data: {},
};
const mockPgClient = {
query: mock(async () => ({ rows: [updatedUser] })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await updateAuthUserTool.execute(
{
user_id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
},
context
);
expect(result).toEqual(updatedUser);
});
test('throws error when user is not found', async () => {
const mockPgClient = {
query: mock(async () => ({ rows: [] })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await expect(
updateAuthUserTool.execute(
{
user_id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
},
context
)
).rejects.toThrow('not found');
});
test('checks pgcrypto when updating password', async () => {
const mockPgClient = {
query: mock(async (sql: string) => {
if (sql.includes('crypt') && sql.includes('SELECT')) {
throw new Error('function crypt does not exist');
}
return { rows: [] };
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await expect(
updateAuthUserTool.execute(
{
user_id: '123e4567-e89b-12d3-a456-426614174000',
password: 'newpassword123',
},
context
)
).rejects.toThrow('pgcrypto');
});
});
});
@@ -0,0 +1,499 @@
/**
* Tests for database-related tools
*
* Tools tested:
* - list_tables
* - list_extensions
* - get_database_connections
* - get_database_stats
* - list_migrations
* - apply_migration
*/
import { describe, test, expect } from 'bun:test';
import { listTablesTool } from '../../tools/list_tables.js';
import { listExtensionsTool } from '../../tools/list_extensions.js';
import { getDatabaseConnectionsTool } from '../../tools/get_database_connections.js';
import { getDatabaseStatsTool } from '../../tools/get_database_stats.js';
import {
createMockClient,
createMockContext,
createSuccessResponse,
createErrorResponse,
testData,
} from '../helpers/mocks.js';
describe('listTablesTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(listTablesTool.name).toBe('list_tables');
});
test('has description', () => {
expect(listTablesTool.description).toBeDefined();
expect(listTablesTool.description).toContain('table');
});
test('has input and output schemas', () => {
expect(listTablesTool.inputSchema).toBeDefined();
expect(listTablesTool.outputSchema).toBeDefined();
expect(listTablesTool.mcpInputSchema).toBeDefined();
});
});
describe('execute', () => {
test('returns list of tables', async () => {
const tables = [
{ schema: 'public', name: 'users', comment: 'User accounts' },
{ schema: 'public', name: 'posts', comment: null },
];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(tables),
});
const context = createMockContext(mockClient);
const result = await listTablesTool.execute({}, context);
expect(result).toEqual(tables);
});
test('returns empty array when no tables exist', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await listTablesTool.execute({}, context);
expect(result).toEqual([]);
});
test('throws error on SQL failure', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('permission denied', '42501'),
});
const context = createMockContext(mockClient);
await expect(listTablesTool.execute({}, context)).rejects.toThrow('SQL Error');
});
test('uses read-only mode for query via service role RPC', async () => {
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: true,
serviceRoleRpcResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
await listTablesTool.execute({}, context);
// When using service role RPC, should be called with readOnly=true
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledWith(
expect.any(String),
true
);
});
});
describe('output validation', () => {
test('validates correct table structure', () => {
const validOutput = [
{ schema: 'public', name: 'users', comment: 'User table' },
{ schema: 'public', name: 'posts', comment: null },
];
const result = listTablesTool.outputSchema.safeParse(validOutput);
expect(result.success).toBe(true);
});
test('rejects missing schema field', () => {
const invalidOutput = [{ name: 'users', comment: null }];
const result = listTablesTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
test('rejects missing name field', () => {
const invalidOutput = [{ schema: 'public', comment: null }];
const result = listTablesTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
});
});
describe('listExtensionsTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(listExtensionsTool.name).toBe('list_extensions');
});
test('has description', () => {
expect(listExtensionsTool.description).toContain('extension');
});
});
describe('execute', () => {
test('returns list of extensions', async () => {
const extensions = [
{ name: 'uuid-ossp', schema: 'extensions', version: '1.1', description: 'UUID functions' },
{ name: 'pgcrypto', schema: 'extensions', version: '1.3', description: null },
];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(extensions),
});
const context = createMockContext(mockClient);
const result = await listExtensionsTool.execute({}, context);
expect(result).toEqual(extensions);
});
test('returns empty array when no extensions installed', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await listExtensionsTool.execute({}, context);
expect(result).toEqual([]);
});
test('throws error on SQL failure', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('access denied', '42501'),
});
const context = createMockContext(mockClient);
await expect(listExtensionsTool.execute({}, context)).rejects.toThrow('SQL Error');
});
});
describe('output validation', () => {
test('validates correct extension structure', () => {
const validOutput = [
{ name: 'uuid-ossp', schema: 'public', version: '1.1', description: 'UUID gen' },
];
const result = listExtensionsTool.outputSchema.safeParse(validOutput);
expect(result.success).toBe(true);
});
test('accepts null description', () => {
const output = [
{ name: 'ext', schema: 'public', version: '1.0', description: null },
];
const result = listExtensionsTool.outputSchema.safeParse(output);
expect(result.success).toBe(true);
});
test('rejects missing required fields', () => {
const invalidOutput = [{ name: 'ext' }];
const result = listExtensionsTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
});
});
describe('getDatabaseConnectionsTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(getDatabaseConnectionsTool.name).toBe('get_database_connections');
});
test('has description about connections', () => {
expect(getDatabaseConnectionsTool.description).toContain('connection');
});
});
describe('execute', () => {
test('returns list of connections', async () => {
const connections = [
{
pid: 12345,
datname: 'postgres',
usename: 'postgres',
application_name: 'psql',
client_addr: '127.0.0.1',
backend_start: '2024-01-01T00:00:00Z',
state: 'active',
query: 'SELECT 1',
},
];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(connections),
});
const context = createMockContext(mockClient);
const result = await getDatabaseConnectionsTool.execute({}, context);
expect(result).toEqual(connections);
});
test('returns empty array when no connections', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await getDatabaseConnectionsTool.execute({}, context);
expect(result).toEqual([]);
});
test('handles connections with null values', async () => {
const connections = [
{
pid: 1,
datname: null,
usename: null,
application_name: null,
client_addr: null,
backend_start: null,
state: null,
query: null,
},
];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(connections),
});
const context = createMockContext(mockClient);
const result = await getDatabaseConnectionsTool.execute({}, context);
expect(result).toEqual(connections);
});
test('throws error on SQL failure', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('permission denied for pg_stat_activity', '42501'),
});
const context = createMockContext(mockClient);
await expect(getDatabaseConnectionsTool.execute({}, context)).rejects.toThrow('SQL Error');
});
});
describe('output validation', () => {
test('requires pid to be a number', () => {
const invalidOutput = [{ pid: 'not-a-number' }];
const result = getDatabaseConnectionsTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
test('accepts complete connection object', () => {
const validOutput = [
{
pid: 123,
datname: 'db',
usename: 'user',
application_name: 'app',
client_addr: '127.0.0.1',
backend_start: '2024-01-01',
state: 'idle',
query: 'SELECT 1',
},
];
const result = getDatabaseConnectionsTool.outputSchema.safeParse(validOutput);
expect(result.success).toBe(true);
});
});
});
describe('getDatabaseStatsTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(getDatabaseStatsTool.name).toBe('get_database_stats');
});
test('has description about statistics', () => {
expect(getDatabaseStatsTool.description).toContain('statistic');
});
});
describe('execute', () => {
test('returns combined database and bgwriter stats', async () => {
const dbStats = [
{
datname: 'postgres',
numbackends: 5,
xact_commit: '1000',
xact_rollback: '10',
blks_read: '500',
blks_hit: '9500',
tup_returned: '10000',
tup_fetched: '5000',
tup_inserted: '100',
tup_updated: '50',
tup_deleted: '10',
conflicts: '0',
temp_files: '0',
temp_bytes: '0',
deadlocks: '0',
checksum_failures: null,
checksum_last_failure: null,
blk_read_time: 1.5,
blk_write_time: 0.5,
stats_reset: '2024-01-01T00:00:00Z',
},
];
const bgWriterStats = [
{
checkpoints_timed: '100',
checkpoints_req: '5',
checkpoint_write_time: 1000.0,
checkpoint_sync_time: 50.0,
buffers_checkpoint: '500',
buffers_clean: '100',
maxwritten_clean: '0',
buffers_backend: '50',
buffers_backend_fsync: '0',
buffers_alloc: '1000',
stats_reset: '2024-01-01T00:00:00Z',
},
];
// Mock client needs to return different results for the two queries
let callCount = 0;
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeSqlWithPg as ReturnType<typeof import('bun:test').mock>).mockImplementation(
async () => {
callCount++;
return callCount === 1 ? dbStats : bgWriterStats;
}
);
const context = createMockContext(mockClient);
const result = await getDatabaseStatsTool.execute({}, context);
expect(result).toHaveProperty('database_stats');
expect(result).toHaveProperty('bgwriter_stats');
expect(result.database_stats).toEqual(dbStats);
expect(result.bgwriter_stats).toEqual(bgWriterStats);
});
test('throws error when database stats query fails', async () => {
let callCount = 0;
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeSqlWithPg as ReturnType<typeof import('bun:test').mock>).mockImplementation(
async () => {
callCount++;
if (callCount === 1) {
return createErrorResponse('query failed', 'ERROR');
}
return [];
}
);
const context = createMockContext(mockClient);
await expect(getDatabaseStatsTool.execute({}, context)).rejects.toThrow('SQL Error');
});
test('throws error when bgwriter stats query fails', async () => {
let callCount = 0;
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeSqlWithPg as ReturnType<typeof import('bun:test').mock>).mockImplementation(
async () => {
callCount++;
if (callCount === 2) {
return createErrorResponse('query failed', 'ERROR');
}
return [
{
datname: 'test',
numbackends: 1,
xact_commit: '0',
xact_rollback: '0',
blks_read: '0',
blks_hit: '0',
tup_returned: '0',
tup_fetched: '0',
tup_inserted: '0',
tup_updated: '0',
tup_deleted: '0',
conflicts: '0',
temp_files: '0',
temp_bytes: '0',
deadlocks: '0',
checksum_failures: null,
checksum_last_failure: null,
blk_read_time: 0,
blk_write_time: 0,
stats_reset: null,
},
];
}
);
const context = createMockContext(mockClient);
await expect(getDatabaseStatsTool.execute({}, context)).rejects.toThrow('SQL Error');
});
});
describe('output validation', () => {
test('validates correct stats structure', () => {
const validOutput = {
database_stats: [
{
datname: 'test',
numbackends: 1,
xact_commit: '0',
xact_rollback: '0',
blks_read: '0',
blks_hit: '0',
tup_returned: '0',
tup_fetched: '0',
tup_inserted: '0',
tup_updated: '0',
tup_deleted: '0',
conflicts: '0',
temp_files: '0',
temp_bytes: '0',
deadlocks: '0',
checksum_failures: null,
checksum_last_failure: null,
blk_read_time: 0,
blk_write_time: 0,
stats_reset: null,
},
],
bgwriter_stats: [
{
checkpoints_timed: '0',
checkpoints_req: '0',
checkpoint_write_time: 0,
checkpoint_sync_time: 0,
buffers_checkpoint: '0',
buffers_clean: '0',
maxwritten_clean: '0',
buffers_backend: '0',
buffers_backend_fsync: '0',
buffers_alloc: '0',
stats_reset: null,
},
],
};
const result = getDatabaseStatsTool.outputSchema.safeParse(validOutput);
expect(result.success).toBe(true);
});
test('rejects missing database_stats', () => {
const invalidOutput = { bgwriter_stats: [] };
const result = getDatabaseStatsTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
test('rejects missing bgwriter_stats', () => {
const invalidOutput = { database_stats: [] };
const result = getDatabaseStatsTool.outputSchema.safeParse(invalidOutput);
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,248 @@
/**
* Tests for execute_sql tool
*
* Tests the SQL execution tool that allows arbitrary SQL queries.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import { executeSqlTool } from '../../tools/execute_sql.js';
import {
createMockClient,
createMockContext,
createSuccessResponse,
createErrorResponse,
} from '../helpers/mocks.js';
describe('executeSqlTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(executeSqlTool.name).toBe('execute_sql');
});
test('has description', () => {
expect(executeSqlTool.description).toBeDefined();
expect(executeSqlTool.description.length).toBeGreaterThan(0);
});
test('has input schema', () => {
expect(executeSqlTool.inputSchema).toBeDefined();
});
test('has MCP input schema', () => {
expect(executeSqlTool.mcpInputSchema).toBeDefined();
expect(executeSqlTool.mcpInputSchema.type).toBe('object');
expect(executeSqlTool.mcpInputSchema.properties.sql).toBeDefined();
});
test('has output schema', () => {
expect(executeSqlTool.outputSchema).toBeDefined();
});
});
describe('input validation', () => {
test('validates sql is required', () => {
const result = executeSqlTool.inputSchema.safeParse({});
expect(result.success).toBe(false);
});
test('validates sql must be string', () => {
const result = executeSqlTool.inputSchema.safeParse({ sql: 123 });
expect(result.success).toBe(false);
});
test('accepts valid sql string', () => {
const result = executeSqlTool.inputSchema.safeParse({ sql: 'SELECT 1' });
expect(result.success).toBe(true);
});
test('read_only defaults to false', () => {
const result = executeSqlTool.inputSchema.safeParse({ sql: 'SELECT 1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.read_only).toBe(false);
}
});
test('accepts read_only boolean', () => {
const result = executeSqlTool.inputSchema.safeParse({
sql: 'SELECT 1',
read_only: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.read_only).toBe(true);
}
});
});
describe('execute', () => {
test('returns results for successful query', async () => {
const expectedRows = [{ id: 1, name: 'test' }, { id: 2, name: 'test2' }];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(expectedRows),
});
const context = createMockContext(mockClient);
const result = await executeSqlTool.execute({ sql: 'SELECT * FROM users' }, context);
expect(result).toEqual(expectedRows);
});
test('returns empty array for query with no results', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await executeSqlTool.execute(
{ sql: 'SELECT * FROM users WHERE 1=0' },
context
);
expect(result).toEqual([]);
});
test('throws error for SQL error response', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('syntax error at position 1', '42601'),
});
const context = createMockContext(mockClient);
await expect(
executeSqlTool.execute({ sql: 'INVALID SQL' }, context)
).rejects.toThrow('SQL Error (42601): syntax error at position 1');
});
test('uses pg connection when available', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([{ result: 1 }]),
});
const context = createMockContext(mockClient);
await executeSqlTool.execute({ sql: 'SELECT 1 as result' }, context);
expect(mockClient.executeSqlWithPg).toHaveBeenCalled();
expect(mockClient.executeSqlViaRpc).not.toHaveBeenCalled();
});
test('falls back to service role RPC when pg is not available', async () => {
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: true,
serviceRoleRpcResult: createSuccessResponse([{ result: 1 }]),
});
const context = createMockContext(mockClient);
await executeSqlTool.execute({ sql: 'SELECT 1 as result' }, context);
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalled();
});
test('passes read_only flag to service role RPC', async () => {
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: true,
serviceRoleRpcResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
await executeSqlTool.execute(
{ sql: 'SELECT 1', read_only: true },
context
);
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledWith('SELECT 1', true);
});
test('throws error when neither pg nor service role is available', async () => {
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: false,
});
const context = createMockContext(mockClient);
await expect(
executeSqlTool.execute({ sql: 'SELECT 1' }, context)
).rejects.toThrow('execute_sql requires either a direct database connection');
});
test('handles complex query results', async () => {
const complexResult = [
{
id: 1,
created_at: '2024-01-01T00:00:00Z',
metadata: { key: 'value' },
tags: ['a', 'b', 'c'],
},
];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(complexResult),
});
const context = createMockContext(mockClient);
const result = await executeSqlTool.execute(
{ sql: 'SELECT * FROM complex_table' },
context
);
expect(result).toEqual(complexResult);
});
test('handles INSERT returning result', async () => {
const insertResult = [{ id: 42 }];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(insertResult),
});
const context = createMockContext(mockClient);
const result = await executeSqlTool.execute(
{ sql: "INSERT INTO users (name) VALUES ('test') RETURNING id" },
context
);
expect(result).toEqual(insertResult);
});
test('handles UPDATE with no rows affected', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await executeSqlTool.execute(
{ sql: "UPDATE users SET name = 'test' WHERE id = -1" },
context
);
expect(result).toEqual([]);
});
});
describe('output validation', () => {
test('output schema accepts array of objects', () => {
const result = executeSqlTool.outputSchema.safeParse([
{ id: 1, name: 'test' },
]);
expect(result.success).toBe(true);
});
test('output schema accepts empty array', () => {
const result = executeSqlTool.outputSchema.safeParse([]);
expect(result.success).toBe(true);
});
test('output schema accepts array with any structure', () => {
const result = executeSqlTool.outputSchema.safeParse([
{ complex: { nested: { data: [1, 2, 3] } } },
]);
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,253 @@
/**
* Tests for miscellaneous tools
*
* Tools tested:
* - get_project_url
* - verify_jwt_secret
* - generate_typescript_types
* - list_realtime_publications
* - list_cron_jobs
* - list_vector_indexes
*/
import { describe, test, expect, mock } from 'bun:test';
import { getProjectUrlTool } from '../../tools/get_project_url.js';
import { verifyJwtSecretTool } from '../../tools/verify_jwt_secret.js';
import { generateTypesTool } from '../../tools/generate_typescript_types.js';
import {
createMockClient,
createMockContext,
} from '../helpers/mocks.js';
describe('getProjectUrlTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(getProjectUrlTool.name).toBe('get_project_url');
});
test('has description', () => {
expect(getProjectUrlTool.description).toContain('URL');
});
});
describe('input validation', () => {
test('accepts empty input', () => {
const result = getProjectUrlTool.inputSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe('execute', () => {
test('returns project URL', async () => {
const mockClient = createMockClient({
supabaseUrl: 'https://my-project.supabase.co',
});
const context = createMockContext(mockClient);
const result = await getProjectUrlTool.execute({}, context);
expect(result.project_url).toBe('https://my-project.supabase.co');
});
test('returns configured URL from client', async () => {
const customUrl = 'https://custom.supabase.example.com';
const mockClient = createMockClient({ supabaseUrl: customUrl });
const context = createMockContext(mockClient);
const result = await getProjectUrlTool.execute({}, context);
expect(result.project_url).toBe(customUrl);
});
});
describe('output validation', () => {
test('validates URL format', () => {
const result = getProjectUrlTool.outputSchema.safeParse({
project_url: 'https://example.com',
});
expect(result.success).toBe(true);
});
test('rejects invalid URL', () => {
const result = getProjectUrlTool.outputSchema.safeParse({
project_url: 'not-a-url',
});
expect(result.success).toBe(false);
});
});
});
describe('verifyJwtSecretTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(verifyJwtSecretTool.name).toBe('verify_jwt_secret');
});
test('has description about JWT', () => {
expect(verifyJwtSecretTool.description).toContain('JWT');
});
});
describe('execute', () => {
test('returns found status when JWT secret is configured (no preview for security)', async () => {
const mockClient = createMockClient({ jwtSecret: 'my-secret-jwt-key-12345' });
const context = createMockContext(mockClient);
const result = await verifyJwtSecretTool.execute({}, context);
expect(result.jwt_secret_status).toBe('found');
// SECURITY: jwt_secret_preview was removed to avoid leaking secret info
expect('jwt_secret_preview' in result).toBe(false);
});
test('returns not_configured status when JWT secret is missing', async () => {
const mockClient = createMockClient({ jwtSecret: undefined });
mockClient.getJwtSecret = () => undefined;
const context = createMockContext(mockClient);
const result = await verifyJwtSecretTool.execute({}, context);
expect(result.jwt_secret_status).toBe('not_configured');
});
});
describe('output validation', () => {
test('validates found status', () => {
const result = verifyJwtSecretTool.outputSchema.safeParse({
jwt_secret_status: 'found',
});
expect(result.success).toBe(true);
});
test('validates not_configured status', () => {
const result = verifyJwtSecretTool.outputSchema.safeParse({
jwt_secret_status: 'not_configured',
});
expect(result.success).toBe(true);
});
test('rejects invalid status', () => {
const result = verifyJwtSecretTool.outputSchema.safeParse({
jwt_secret_status: 'invalid',
});
expect(result.success).toBe(false);
});
});
});
describe('generateTypesTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(generateTypesTool.name).toBe('generate_typescript_types');
});
test('has description about TypeScript types', () => {
expect(generateTypesTool.description).toContain('TypeScript');
});
});
describe('input validation', () => {
test('requires output_path', () => {
const result = generateTypesTool.inputSchema.safeParse({});
expect(result.success).toBe(false);
});
test('accepts valid input', () => {
const result = generateTypesTool.inputSchema.safeParse({
output_path: '/path/to/types.ts',
});
expect(result.success).toBe(true);
});
test('defaults included_schemas to public', () => {
const result = generateTypesTool.inputSchema.safeParse({
output_path: '/path/to/types.ts',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.included_schemas).toEqual(['public']);
}
});
test('accepts custom schemas', () => {
const result = generateTypesTool.inputSchema.safeParse({
output_path: '/path/to/types.ts',
included_schemas: ['public', 'auth', 'storage'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.included_schemas).toEqual(['public', 'auth', 'storage']);
}
});
test('defaults output_filename', () => {
const result = generateTypesTool.inputSchema.safeParse({
output_path: '/path/to/types.ts',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.output_filename).toBe('database.types.ts');
}
});
});
describe('execute', () => {
test('returns error when DATABASE_URL is not configured', async () => {
const mockClient = createMockClient({ dbUrl: undefined });
mockClient.getDbUrl = () => undefined;
const context = createMockContext(mockClient);
const result = await generateTypesTool.execute(
{ output_path: '/tmp/types.ts' },
context
);
expect(result.success).toBe(false);
expect(result.message).toContain('DATABASE_URL');
});
test('includes platform in response', async () => {
const mockClient = createMockClient({ dbUrl: undefined });
mockClient.getDbUrl = () => undefined;
const context = createMockContext(mockClient);
const result = await generateTypesTool.execute(
{ output_path: '/tmp/types.ts' },
context
);
expect(result.platform).toBeDefined();
expect(['win32', 'darwin', 'linux', 'freebsd', 'openbsd']).toContain(result.platform);
});
});
describe('output validation', () => {
test('validates success response', () => {
const result = generateTypesTool.outputSchema.safeParse({
success: true,
message: 'Types generated',
types: 'export type User = {...}',
file_path: '/path/to/types.ts',
platform: 'linux',
});
expect(result.success).toBe(true);
});
test('validates failure response', () => {
const result = generateTypesTool.outputSchema.safeParse({
success: false,
message: 'Failed to generate types',
platform: 'darwin',
});
expect(result.success).toBe(true);
});
test('requires platform field', () => {
const result = generateTypesTool.outputSchema.safeParse({
success: false,
message: 'Error',
});
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,380 @@
/**
* Tests for storage-related tools
*
* Tools tested:
* - list_storage_buckets
* - list_storage_objects
* - get_storage_config
* - update_storage_config
*/
import { describe, test, expect, mock } from 'bun:test';
import { listStorageBucketsTool } from '../../tools/list_storage_buckets.js';
import { listStorageObjectsTool } from '../../tools/list_storage_objects.js';
import {
createMockClient,
createMockContext,
createSuccessResponse,
createErrorResponse,
testData,
} from '../helpers/mocks.js';
describe('listStorageBucketsTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(listStorageBucketsTool.name).toBe('list_storage_buckets');
});
test('has description', () => {
expect(listStorageBucketsTool.description).toContain('bucket');
});
test('has input and output schemas', () => {
expect(listStorageBucketsTool.inputSchema).toBeDefined();
expect(listStorageBucketsTool.outputSchema).toBeDefined();
});
});
describe('input validation', () => {
test('accepts empty input', () => {
const result = listStorageBucketsTool.inputSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe('execute', () => {
test('returns list of buckets', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(testData.buckets),
});
const context = createMockContext(mockClient);
const result = await listStorageBucketsTool.execute({}, context);
expect(result).toEqual(testData.buckets);
});
test('returns empty array when no buckets', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
const result = await listStorageBucketsTool.execute({}, context);
expect(result).toEqual([]);
});
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(listStorageBucketsTool.execute({}, context)).rejects.toThrow(
'Direct database connection'
);
});
test('throws error on SQL failure', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createErrorResponse('relation "storage.buckets" does not exist', '42P01'),
});
const context = createMockContext(mockClient);
await expect(listStorageBucketsTool.execute({}, context)).rejects.toThrow('SQL Error');
});
test('uses pg connection directly', async () => {
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse([]),
});
const context = createMockContext(mockClient);
await listStorageBucketsTool.execute({}, context);
expect(mockClient.executeSqlWithPg).toHaveBeenCalled();
});
});
describe('output validation', () => {
test('validates correct bucket structure', () => {
const result = listStorageBucketsTool.outputSchema.safeParse(testData.buckets);
expect(result.success).toBe(true);
});
test('accepts buckets with all nullable fields as null', () => {
const bucketWithNulls = [{
id: 'test-id',
name: 'test-bucket',
owner: null,
public: false,
avif_autodetection: false,
file_size_limit: null,
allowed_mime_types: null,
created_at: null,
updated_at: null,
}];
const result = listStorageBucketsTool.outputSchema.safeParse(bucketWithNulls);
expect(result.success).toBe(true);
});
test('rejects bucket without required id', () => {
const invalid = [{ name: 'test' }];
const result = listStorageBucketsTool.outputSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test('rejects bucket with invalid public type', () => {
const invalid = [{
id: 'test',
name: 'test',
owner: null,
public: 'yes', // should be boolean
avif_autodetection: false,
file_size_limit: null,
allowed_mime_types: null,
created_at: null,
updated_at: null,
}];
const result = listStorageBucketsTool.outputSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
});
});
describe('listStorageObjectsTool', () => {
describe('metadata', () => {
test('has correct name', () => {
expect(listStorageObjectsTool.name).toBe('list_storage_objects');
});
test('has description', () => {
expect(listStorageObjectsTool.description).toContain('object');
});
});
describe('input validation', () => {
test('requires bucket_id', () => {
const result = listStorageObjectsTool.inputSchema.safeParse({});
expect(result.success).toBe(false);
});
test('accepts bucket_id only', () => {
const result = listStorageObjectsTool.inputSchema.safeParse({ bucket_id: 'test-bucket' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(100);
expect(result.data.offset).toBe(0);
}
});
test('accepts all parameters', () => {
const result = listStorageObjectsTool.inputSchema.safeParse({
bucket_id: 'test-bucket',
limit: 50,
offset: 10,
prefix: 'public/',
});
expect(result.success).toBe(true);
});
test('rejects negative limit', () => {
const result = listStorageObjectsTool.inputSchema.safeParse({
bucket_id: 'test',
limit: -1,
});
expect(result.success).toBe(false);
});
test('rejects negative offset', () => {
const result = listStorageObjectsTool.inputSchema.safeParse({
bucket_id: 'test',
offset: -1,
});
expect(result.success).toBe(false);
});
});
describe('execute', () => {
test('returns list of objects', async () => {
const mockPgClient = {
query: mock(async () => ({ rows: testData.storageObjects })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await listStorageObjectsTool.execute(
{ bucket_id: 'avatars' },
context
);
expect(result.length).toBe(testData.storageObjects.length);
});
test('returns empty array when no objects', async () => {
const mockPgClient = {
query: mock(async () => ({ rows: [] })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
const result = await listStorageObjectsTool.execute(
{ bucket_id: 'empty-bucket' },
context
);
expect(result).toEqual([]);
});
test('throws error when pg is not available', async () => {
const mockClient = createMockClient({ pgAvailable: false });
const context = createMockContext(mockClient);
await expect(
listStorageObjectsTool.execute({ bucket_id: 'test' }, context)
).rejects.toThrow('Direct database connection');
});
test('uses transaction for parameterized query', async () => {
const mockPgClient = {
query: mock(async () => ({ rows: [] })),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await listStorageObjectsTool.execute({ bucket_id: 'test' }, context);
expect(mockClient.executeTransactionWithPg).toHaveBeenCalled();
});
test('applies prefix filter in query', async () => {
let executedSql = '';
let executedParams: unknown[] = [];
const mockPgClient = {
query: mock(async (sql: string, params: unknown[]) => {
executedSql = sql;
executedParams = params;
return { rows: [] };
}),
};
const mockClient = createMockClient({ pgAvailable: true });
(mockClient.executeTransactionWithPg as ReturnType<typeof mock>).mockImplementation(
async (callback: (client: unknown) => Promise<unknown>) => {
return callback(mockPgClient);
}
);
const context = createMockContext(mockClient);
await listStorageObjectsTool.execute(
{ bucket_id: 'test', prefix: 'images/' },
context
);
expect(executedSql).toContain('LIKE');
expect(executedParams).toContain('images/%');
});
});
describe('output validation', () => {
test('validates correct object structure', () => {
const validObjects = [{
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'file.txt',
bucket_id: 'test',
owner: '123e4567-e89b-12d3-a456-426614174001',
version: null,
mimetype: 'text/plain',
size: 1024,
metadata: { mimetype: 'text/plain', size: 1024 },
created_at: '2024-01-01',
updated_at: null,
last_accessed_at: null,
}];
const result = listStorageObjectsTool.outputSchema.safeParse(validObjects);
expect(result.success).toBe(true);
});
test('transforms string size to number', () => {
const objectWithStringSize = [{
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'file.txt',
bucket_id: 'test',
owner: null,
version: null,
mimetype: null,
size: '1024', // string
metadata: null,
created_at: null,
updated_at: null,
last_accessed_at: null,
}];
const result = listStorageObjectsTool.outputSchema.safeParse(objectWithStringSize);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data[0].size).toBe(1024);
}
});
test('handles null size', () => {
const objectWithNullSize = [{
id: '123e4567-e89b-12d3-a456-426614174000',
name: null,
bucket_id: 'test',
owner: null,
version: null,
mimetype: null,
size: null,
metadata: null,
created_at: null,
updated_at: null,
last_accessed_at: null,
}];
const result = listStorageObjectsTool.outputSchema.safeParse(objectWithNullSize);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data[0].size).toBeNull();
}
});
test('rejects invalid UUID for id', () => {
const invalid = [{
id: 'not-a-uuid',
name: 'file.txt',
bucket_id: 'test',
owner: null,
version: null,
mimetype: null,
size: null,
metadata: null,
created_at: null,
updated_at: null,
last_accessed_at: null,
}];
const result = listStorageObjectsTool.outputSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
});
});
@@ -0,0 +1,177 @@
import { describe, test, expect } from 'bun:test';
import type {
SelfhostedSupabaseClientOptions,
SqlSuccessResponse,
SqlErrorResponse,
SqlExecutionResult,
AuthUser,
StorageBucket,
StorageObject,
} from '../types/index.js';
describe('Type Definitions', () => {
describe('SelfhostedSupabaseClientOptions', () => {
test('required fields are enforced at compile time', () => {
const validOptions: SelfhostedSupabaseClientOptions = {
supabaseUrl: 'http://localhost:54321',
supabaseAnonKey: 'test-anon-key',
};
expect(validOptions.supabaseUrl).toBe('http://localhost:54321');
expect(validOptions.supabaseAnonKey).toBe('test-anon-key');
});
test('optional fields can be provided', () => {
const fullOptions: SelfhostedSupabaseClientOptions = {
supabaseUrl: 'http://localhost:54321',
supabaseAnonKey: 'test-anon-key',
supabaseServiceRoleKey: 'service-key',
databaseUrl: 'postgresql://localhost:5432/db',
jwtSecret: 'secret',
};
expect(fullOptions.supabaseServiceRoleKey).toBe('service-key');
expect(fullOptions.databaseUrl).toBe('postgresql://localhost:5432/db');
expect(fullOptions.jwtSecret).toBe('secret');
});
});
describe('SqlExecutionResult', () => {
test('SqlSuccessResponse is array of records', () => {
const success: SqlSuccessResponse = [
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
];
expect(Array.isArray(success)).toBe(true);
expect(success.length).toBe(2);
});
test('SqlErrorResponse has error object', () => {
const error: SqlErrorResponse = {
error: {
message: 'Test error',
code: 'TEST001',
details: 'Some details',
hint: 'Try this',
},
};
expect(error.error.message).toBe('Test error');
expect(error.error.code).toBe('TEST001');
});
test('SqlExecutionResult can be either type', () => {
const successResult: SqlExecutionResult = [{ id: 1 }];
const errorResult: SqlExecutionResult = {
error: { message: 'error' },
};
// Type narrowing
if ('error' in errorResult) {
expect(errorResult.error.message).toBe('error');
}
if (Array.isArray(successResult)) {
expect(successResult[0].id).toBe(1);
}
});
});
describe('AuthUser', () => {
test('can create valid AuthUser object', () => {
const user: AuthUser = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
role: 'authenticated',
created_at: '2024-01-01T00:00:00Z',
last_sign_in_at: '2024-01-02T00:00:00Z',
raw_app_meta_data: { provider: 'email' },
raw_user_meta_data: { name: 'Test User' },
};
expect(user.id).toBe('123e4567-e89b-12d3-a456-426614174000');
expect(user.email).toBe('[email protected]');
});
test('nullable fields can be null', () => {
const user: AuthUser = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: null,
role: null,
created_at: null,
last_sign_in_at: null,
raw_app_meta_data: null,
raw_user_meta_data: null,
};
expect(user.email).toBeNull();
expect(user.role).toBeNull();
});
});
describe('StorageBucket', () => {
test('can create valid StorageBucket object', () => {
const bucket: StorageBucket = {
id: 'avatars',
name: 'avatars',
owner: '123e4567-e89b-12d3-a456-426614174000',
public: true,
avif_autodetection: false,
file_size_limit: 5242880,
allowed_mime_types: ['image/png', 'image/jpeg'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
expect(bucket.id).toBe('avatars');
expect(bucket.public).toBe(true);
});
test('nullable fields can be null', () => {
const bucket: StorageBucket = {
id: 'documents',
name: 'documents',
owner: null,
public: false,
avif_autodetection: false,
file_size_limit: null,
allowed_mime_types: null,
created_at: null,
updated_at: null,
};
expect(bucket.owner).toBeNull();
expect(bucket.file_size_limit).toBeNull();
});
});
describe('StorageObject', () => {
test('can create valid StorageObject object', () => {
const obj: StorageObject = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'image.png',
bucket_id: 'avatars',
owner: '123e4567-e89b-12d3-a456-426614174001',
version: '1',
mimetype: 'image/png',
size: 1024,
metadata: { contentType: 'image/png' },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_accessed_at: '2024-01-02T00:00:00Z',
};
expect(obj.name).toBe('image.png');
expect(obj.size).toBe(1024);
});
test('nullable fields can be null', () => {
const obj: StorageObject = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: null,
bucket_id: 'documents',
owner: null,
version: null,
mimetype: null,
size: null,
metadata: null,
created_at: null,
updated_at: null,
last_accessed_at: null,
};
expect(obj.name).toBeNull();
expect(obj.size).toBeNull();
});
});
});
@@ -0,0 +1,209 @@
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
import { z } from 'zod';
import { isSqlErrorResponse, handleSqlResponse, executeSqlWithFallback, runExternalCommand } from '../tools/utils.js';
import type { SqlExecutionResult, SqlErrorResponse, SqlSuccessResponse } from '../types/index.js';
import { createMockClient, createSuccessResponse, createErrorResponse } from './helpers/mocks.js';
describe('utils', () => {
describe('isSqlErrorResponse', () => {
test('returns true for error response', () => {
const errorResult: SqlErrorResponse = {
error: {
message: 'Test error',
code: 'TEST001',
},
};
expect(isSqlErrorResponse(errorResult)).toBe(true);
});
test('returns false for success response', () => {
const successResult: SqlSuccessResponse = [
{ id: 1, name: 'test' },
];
expect(isSqlErrorResponse(successResult)).toBe(false);
});
test('returns false for empty array (valid success)', () => {
const emptyResult: SqlSuccessResponse = [];
expect(isSqlErrorResponse(emptyResult)).toBe(false);
});
});
describe('handleSqlResponse', () => {
const testSchema = z.array(
z.object({
id: z.number(),
name: z.string(),
})
);
test('returns parsed data for valid success response', () => {
const successResult: SqlSuccessResponse = [
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
];
const result = handleSqlResponse(successResult, testSchema);
expect(result).toEqual([
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
]);
});
test('throws error for SQL error response', () => {
const errorResult: SqlErrorResponse = {
error: {
message: 'Database error',
code: 'DB001',
},
};
expect(() => handleSqlResponse(errorResult, testSchema)).toThrow(
'SQL Error (DB001): Database error'
);
});
test('throws error for schema validation failure', () => {
const invalidData: SqlSuccessResponse = [
{ id: 'not-a-number', name: 'test' } as unknown as Record<string, unknown>,
];
expect(() => handleSqlResponse(invalidData, testSchema)).toThrow(
'Schema validation failed'
);
});
test('handles empty array with array schema', () => {
const emptyResult: SqlSuccessResponse = [];
const result = handleSqlResponse(emptyResult, testSchema);
expect(result).toEqual([]);
});
test('error message includes path for nested validation errors', () => {
const nestedSchema = z.array(
z.object({
user: z.object({
email: z.string().email('Invalid email'),
}),
})
);
const invalidData: SqlSuccessResponse = [
{ user: { email: 'not-an-email' } },
];
expect(() => handleSqlResponse(invalidData, nestedSchema)).toThrow(
/user\.email/
);
});
});
describe('executeSqlWithFallback', () => {
test('uses direct pg connection when available', async () => {
const expectedRows = [{ id: 1, name: 'test' }];
const mockClient = createMockClient({
pgAvailable: true,
pgResult: createSuccessResponse(expectedRows),
rpcResult: createSuccessResponse([{ id: 2, name: 'rpc' }]),
});
const result = await executeSqlWithFallback(mockClient, 'SELECT * FROM users');
expect(result).toEqual(expectedRows);
expect(mockClient.executeSqlWithPg).toHaveBeenCalledTimes(1);
expect(mockClient.executeSqlViaRpc).not.toHaveBeenCalled();
});
test('falls back to service role RPC when pg is not available', async () => {
const expectedRows = [{ id: 1, name: 'service-role-result' }];
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: true,
serviceRoleRpcResult: createSuccessResponse(expectedRows),
});
const result = await executeSqlWithFallback(mockClient, 'SELECT * FROM users', true);
expect(result).toEqual(expectedRows);
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledTimes(1);
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledWith('SELECT * FROM users', true);
});
test('propagates error from pg connection', async () => {
const errorResponse = createErrorResponse('Connection failed', 'CONN_ERR');
const mockClient = createMockClient({
pgAvailable: true,
pgResult: errorResponse,
});
const result = await executeSqlWithFallback(mockClient, 'SELECT 1');
expect(result).toEqual(errorResponse);
});
test('propagates error from service role RPC fallback', async () => {
const errorResponse = createErrorResponse('RPC failed', 'RPC_ERR');
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: true,
serviceRoleRpcResult: errorResponse,
});
const result = await executeSqlWithFallback(mockClient, 'SELECT 1');
expect(result).toEqual(errorResponse);
});
test('defaults readOnly to true when using service role RPC', async () => {
const mockClient = createMockClient({ pgAvailable: false, serviceRoleAvailable: true });
await executeSqlWithFallback(mockClient, 'SELECT 1');
expect(mockClient.executeSqlViaServiceRoleRpc).toHaveBeenCalledWith('SELECT 1', true);
});
test('returns error when neither pg nor service role is available', async () => {
const mockClient = createMockClient({
pgAvailable: false,
serviceRoleAvailable: false,
});
const result = await executeSqlWithFallback(mockClient, 'SELECT 1');
expect(result).toHaveProperty('error');
expect((result as { error: { code: string } }).error.code).toBe('MCP_CONFIG_ERROR');
});
});
describe('runExternalCommand', () => {
test('executes command and returns stdout', async () => {
const result = await runExternalCommand('echo "hello world"');
expect(result.stdout.trim()).toBe('hello world');
expect(result.stderr).toBe('');
expect(result.error).toBeNull();
});
test('returns empty stdout for command with no output', async () => {
const result = await runExternalCommand('true');
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
expect(result.error).toBeNull();
});
test('captures stderr and error for failing command', async () => {
const result = await runExternalCommand('ls /nonexistent-directory-12345');
expect(result.error).not.toBeNull();
expect(result.stderr.length).toBeGreaterThan(0);
});
test('returns error for non-existent command', async () => {
const result = await runExternalCommand('nonexistent-command-12345');
expect(result.error).not.toBeNull();
});
test('handles command with exit code', async () => {
const result = await runExternalCommand('exit 1');
expect(result.error).not.toBeNull();
});
});
});