- 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
638 lines
24 KiB
TypeScript
638 lines
24 KiB
TypeScript
/**
|
|
* 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: 'test@example.com' });
|
|
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: 'test@example.com',
|
|
password: '12345', // 5 chars, needs 6
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
test('accepts valid input', () => {
|
|
const result = createAuthUserTool.inputSchema.safeParse({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test('accepts optional role and metadata', () => {
|
|
const result = createAuthUserTool.inputSchema.safeParse({
|
|
email: 'test@example.com',
|
|
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: 'test@example.com', password: 'password123' },
|
|
context
|
|
)
|
|
).rejects.toThrow('Direct database connection');
|
|
});
|
|
|
|
test('creates user via transaction', async () => {
|
|
const createdUser = {
|
|
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
email: 'test@example.com',
|
|
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: 'test@example.com', 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: 'test@example.com', 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: 'test@example.com', 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: 'new@example.com' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
test('validates user_id is UUID', () => {
|
|
const result = updateAuthUserTool.inputSchema.safeParse({
|
|
user_id: 'not-a-uuid',
|
|
email: 'new@example.com',
|
|
});
|
|
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: 'new@example.com',
|
|
});
|
|
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: 'new@example.com',
|
|
},
|
|
context
|
|
)
|
|
).rejects.toThrow('Direct database connection');
|
|
});
|
|
|
|
test('updates user via transaction', async () => {
|
|
const updatedUser = {
|
|
id: '123e4567-e89b-12d3-a456-426614174000',
|
|
email: 'new@example.com',
|
|
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: 'new@example.com',
|
|
},
|
|
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: 'new@example.com',
|
|
},
|
|
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');
|
|
});
|
|
});
|
|
});
|