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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user