feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
424
src/mcpd/tests/auth-bootstrap.test.ts
Normal file
424
src/mcpd/tests/auth-bootstrap.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerAuthRoutes } from '../src/routes/auth.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { AuthService, LoginResult } from '../src/services/auth.service.js';
|
||||
import type { UserService } from '../src/services/user.service.js';
|
||||
import type { GroupService } from '../src/services/group.service.js';
|
||||
import type { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
|
||||
import type { RbacService, RbacAction } from '../src/services/rbac.service.js';
|
||||
import type { SafeUser } from '../src/repositories/user.repository.js';
|
||||
import type { RbacDefinition } from '@prisma/client';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function makeLoginResult(overrides?: Partial<LoginResult>): LoginResult {
|
||||
return {
|
||||
token: 'test-token-123',
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
user: { id: 'user-1', email: 'admin@example.com', role: 'user' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSafeUser(overrides?: Partial<SafeUser>): SafeUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'admin@example.com',
|
||||
name: null,
|
||||
role: 'user',
|
||||
provider: 'local',
|
||||
externalId: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRbacDef(overrides?: Partial<RbacDefinition>): RbacDefinition {
|
||||
return {
|
||||
id: 'rbac-1',
|
||||
name: 'bootstrap-admin',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', resource: '*' },
|
||||
{ role: 'run', action: 'impersonate' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'restore' },
|
||||
{ role: 'run', action: 'audit-purge' },
|
||||
],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface MockDeps {
|
||||
authService: {
|
||||
login: ReturnType<typeof vi.fn>;
|
||||
logout: ReturnType<typeof vi.fn>;
|
||||
findSession: ReturnType<typeof vi.fn>;
|
||||
impersonate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
userService: {
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
getById: ReturnType<typeof vi.fn>;
|
||||
getByEmail: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
groupService: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
getById: ReturnType<typeof vi.fn>;
|
||||
getByName: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
rbacDefinitionService: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
getById: ReturnType<typeof vi.fn>;
|
||||
getByName: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
rbacService: {
|
||||
canAccess: ReturnType<typeof vi.fn>;
|
||||
canRunOperation: ReturnType<typeof vi.fn>;
|
||||
getPermissions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockDeps(): MockDeps {
|
||||
return {
|
||||
authService: {
|
||||
login: vi.fn(async () => makeLoginResult()),
|
||||
logout: vi.fn(async () => {}),
|
||||
findSession: vi.fn(async () => null),
|
||||
impersonate: vi.fn(async () => makeLoginResult({ token: 'impersonated-token' })),
|
||||
},
|
||||
userService: {
|
||||
count: vi.fn(async () => 0),
|
||||
create: vi.fn(async () => makeSafeUser()),
|
||||
list: vi.fn(async () => []),
|
||||
getById: vi.fn(async () => makeSafeUser()),
|
||||
getByEmail: vi.fn(async () => makeSafeUser()),
|
||||
delete: vi.fn(async () => {}),
|
||||
},
|
||||
groupService: {
|
||||
create: vi.fn(async () => ({ id: 'grp-1', name: 'admin', description: 'Bootstrap admin group', members: [] })),
|
||||
list: vi.fn(async () => []),
|
||||
getById: vi.fn(async () => null),
|
||||
getByName: vi.fn(async () => null),
|
||||
update: vi.fn(async () => null),
|
||||
delete: vi.fn(async () => {}),
|
||||
},
|
||||
rbacDefinitionService: {
|
||||
create: vi.fn(async () => makeRbacDef()),
|
||||
list: vi.fn(async () => []),
|
||||
getById: vi.fn(async () => makeRbacDef()),
|
||||
getByName: vi.fn(async () => null),
|
||||
update: vi.fn(async () => makeRbacDef()),
|
||||
delete: vi.fn(async () => {}),
|
||||
},
|
||||
rbacService: {
|
||||
canAccess: vi.fn(async () => false),
|
||||
canRunOperation: vi.fn(async () => false),
|
||||
getPermissions: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(deps: MockDeps): Promise<FastifyInstance> {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
registerAuthRoutes(app, deps as unknown as {
|
||||
authService: AuthService;
|
||||
userService: UserService;
|
||||
groupService: GroupService;
|
||||
rbacDefinitionService: RbacDefinitionService;
|
||||
rbacService: RbacService;
|
||||
});
|
||||
return app.ready();
|
||||
}
|
||||
|
||||
describe('Auth Bootstrap', () => {
|
||||
describe('GET /api/v1/auth/status', () => {
|
||||
it('returns hasUsers: false when no users exist', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(0);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('returns hasUsers: true when users exist', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(1);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/bootstrap', () => {
|
||||
it('creates admin user, admin group, RBAC definition targeting group, and returns session token', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(0);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/bootstrap',
|
||||
payload: { email: 'admin@example.com', password: 'securepass123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json<LoginResult>();
|
||||
expect(body.token).toBe('test-token-123');
|
||||
expect(body.user.email).toBe('admin@example.com');
|
||||
|
||||
// Verify user was created
|
||||
expect(deps.userService.create).toHaveBeenCalledWith({
|
||||
email: 'admin@example.com',
|
||||
password: 'securepass123',
|
||||
});
|
||||
|
||||
// Verify admin group was created with the user as member
|
||||
expect(deps.groupService.create).toHaveBeenCalledWith({
|
||||
name: 'admin',
|
||||
description: 'Bootstrap admin group',
|
||||
members: ['admin@example.com'],
|
||||
});
|
||||
|
||||
// Verify RBAC definition targets the Group, not the User
|
||||
expect(deps.rbacDefinitionService.create).toHaveBeenCalledWith({
|
||||
name: 'bootstrap-admin',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', resource: '*' },
|
||||
{ role: 'run', action: 'impersonate' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'restore' },
|
||||
{ role: 'run', action: 'audit-purge' },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify auto-login was called
|
||||
expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123');
|
||||
});
|
||||
|
||||
it('passes name when provided', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(0);
|
||||
await createApp(deps);
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/bootstrap',
|
||||
payload: { email: 'admin@example.com', password: 'securepass123', name: 'Admin User' },
|
||||
});
|
||||
|
||||
expect(deps.userService.create).toHaveBeenCalledWith({
|
||||
email: 'admin@example.com',
|
||||
password: 'securepass123',
|
||||
name: 'Admin User',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 409 when users already exist', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(1);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/bootstrap',
|
||||
payload: { email: 'admin@example.com', password: 'securepass123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json<{ error: string }>().error).toContain('Users already exist');
|
||||
|
||||
// Should NOT have created user, group, or RBAC
|
||||
expect(deps.userService.create).not.toHaveBeenCalled();
|
||||
expect(deps.groupService.create).not.toHaveBeenCalled();
|
||||
expect(deps.rbacDefinitionService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates email and password via UserService', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.userService.count.mockResolvedValue(0);
|
||||
// Simulate Zod validation error from UserService
|
||||
deps.userService.create.mockRejectedValue(
|
||||
Object.assign(new Error('Validation error'), { statusCode: 400, issues: [] }),
|
||||
);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/bootstrap',
|
||||
payload: { email: 'not-an-email', password: 'short' },
|
||||
});
|
||||
|
||||
// The error handler should handle the validation error
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/login', () => {
|
||||
it('logs in successfully', async () => {
|
||||
const deps = createMockDeps();
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@example.com', password: 'securepass123' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<LoginResult>().token).toBe('test-token-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/logout', () => {
|
||||
it('logs out with valid token', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.authService.findSession.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<{ success: boolean }>().success).toBe(true);
|
||||
expect(deps.authService.logout).toHaveBeenCalledWith('valid-token');
|
||||
});
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
const deps = createMockDeps();
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/impersonate', () => {
|
||||
it('creates session for target user when caller is admin', async () => {
|
||||
const deps = createMockDeps();
|
||||
// Auth: valid session
|
||||
deps.authService.findSession.mockResolvedValue({
|
||||
userId: 'admin-user-id',
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
// RBAC: allow impersonate operation
|
||||
deps.rbacService.canRunOperation.mockResolvedValue(true);
|
||||
// Impersonate returns token for target
|
||||
deps.authService.impersonate.mockResolvedValue(
|
||||
makeLoginResult({ token: 'impersonated-token', user: { id: 'user-2', email: 'target@example.com', role: 'user' } }),
|
||||
);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/impersonate',
|
||||
headers: { authorization: 'Bearer admin-token' },
|
||||
payload: { email: 'target@example.com' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json<LoginResult>();
|
||||
expect(body.token).toBe('impersonated-token');
|
||||
expect(body.user.email).toBe('target@example.com');
|
||||
expect(deps.authService.impersonate).toHaveBeenCalledWith('target@example.com');
|
||||
});
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
const deps = createMockDeps();
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/impersonate',
|
||||
payload: { email: 'target@example.com' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when caller lacks admin permission on users', async () => {
|
||||
const deps = createMockDeps();
|
||||
// Auth: valid session
|
||||
deps.authService.findSession.mockResolvedValue({
|
||||
userId: 'non-admin-id',
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
// RBAC: deny
|
||||
deps.rbacService.canRunOperation.mockResolvedValue(false);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/impersonate',
|
||||
headers: { authorization: 'Bearer regular-token' },
|
||||
payload: { email: 'target@example.com' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 when impersonation target does not exist', async () => {
|
||||
const deps = createMockDeps();
|
||||
// Auth: valid session
|
||||
deps.authService.findSession.mockResolvedValue({
|
||||
userId: 'admin-user-id',
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
// RBAC: allow
|
||||
deps.rbacService.canRunOperation.mockResolvedValue(true);
|
||||
// Impersonate fails — user not found
|
||||
const authError = new Error('User not found');
|
||||
(authError as Error & { statusCode: number }).statusCode = 401;
|
||||
deps.authService.impersonate.mockRejectedValue(authError);
|
||||
await createApp(deps);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/impersonate',
|
||||
headers: { authorization: 'Bearer admin-token' },
|
||||
payload: { email: 'nonexistent@example.com' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.
|
||||
import { registerBackupRoutes } from '../src/routes/backup.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
||||
import type { IGroupRepository } from '../src/repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
|
||||
// Mock data
|
||||
const mockServers = [
|
||||
@@ -31,8 +34,33 @@ const mockSecrets = [
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: 'proj1', name: 'my-project', description: 'Test project',
|
||||
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
|
||||
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
||||
members: [{ id: 'pm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{ id: 'u1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'u2', email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 'g1', name: 'dev-team', description: 'Developers', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
members: [
|
||||
{ id: 'gm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } },
|
||||
{ id: 'gm2', user: { id: 'u2', email: 'bob@test.com', name: null } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRbacDefinitions = [
|
||||
{
|
||||
id: 'rbac1', name: 'admins', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,9 +91,46 @@ function mockProjectRepo(): IProjectRepository {
|
||||
findAll: vi.fn(async () => [...mockProjects]),
|
||||
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
|
||||
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], members: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setServers: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockUsers]),
|
||||
findById: vi.fn(async (id: string) => mockUsers.find((u) => u.id === id) ?? null),
|
||||
findByEmail: vi.fn(async (email: string) => mockUsers.find((u) => u.email === email) ?? null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-u', ...data, provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockUsers[0])),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => mockUsers.length),
|
||||
};
|
||||
}
|
||||
|
||||
function mockGroupRepo(): IGroupRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockGroups]),
|
||||
findById: vi.fn(async (id: string) => mockGroups.find((g) => g.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => mockGroups.find((g) => g.name === name) ?? null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-g', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockGroups[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockGroups.find((g) => g.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
findGroupsForUser: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
function mockRbacRepo(): IRbacDefinitionRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockRbacDefinitions]),
|
||||
findById: vi.fn(async (id: string) => mockRbacDefinitions.find((r) => r.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => mockRbacDefinitions.find((r) => r.name === name) ?? null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-rbac', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockRbacDefinitions[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockRbacDefinitions.find((r) => r.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +175,7 @@ describe('BackupService', () => {
|
||||
let backupService: BackupService;
|
||||
|
||||
beforeEach(() => {
|
||||
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
|
||||
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo(), mockUserRepo(), mockGroupRepo(), mockRbacRepo());
|
||||
});
|
||||
|
||||
it('creates backup with all resources', async () => {
|
||||
@@ -126,11 +191,51 @@ describe('BackupService', () => {
|
||||
expect(bundle.projects[0]!.name).toBe('my-project');
|
||||
});
|
||||
|
||||
it('includes users in backup', async () => {
|
||||
const bundle = await backupService.createBackup();
|
||||
expect(bundle.users).toHaveLength(2);
|
||||
expect(bundle.users![0]!.email).toBe('alice@test.com');
|
||||
expect(bundle.users![0]!.role).toBe('ADMIN');
|
||||
expect(bundle.users![1]!.email).toBe('bob@test.com');
|
||||
expect(bundle.users![1]!.provider).toBe('oidc');
|
||||
});
|
||||
|
||||
it('includes groups in backup with member emails', async () => {
|
||||
const bundle = await backupService.createBackup();
|
||||
expect(bundle.groups).toHaveLength(1);
|
||||
expect(bundle.groups![0]!.name).toBe('dev-team');
|
||||
expect(bundle.groups![0]!.memberEmails).toEqual(['alice@test.com', 'bob@test.com']);
|
||||
});
|
||||
|
||||
it('includes rbac bindings in backup', async () => {
|
||||
const bundle = await backupService.createBackup();
|
||||
expect(bundle.rbacBindings).toHaveLength(1);
|
||||
expect(bundle.rbacBindings![0]!.name).toBe('admins');
|
||||
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
|
||||
});
|
||||
|
||||
it('includes enriched projects with server names and members', async () => {
|
||||
const bundle = await backupService.createBackup();
|
||||
const proj = bundle.projects[0]!;
|
||||
expect(proj.proxyMode).toBe('direct');
|
||||
expect(proj.serverNames).toEqual(['github']);
|
||||
expect(proj.members).toEqual(['alice@test.com']);
|
||||
});
|
||||
|
||||
it('filters resources', async () => {
|
||||
const bundle = await backupService.createBackup({ resources: ['servers'] });
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.secrets).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
expect(bundle.users).toHaveLength(0);
|
||||
expect(bundle.groups).toHaveLength(0);
|
||||
expect(bundle.rbacBindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters to only users', async () => {
|
||||
const bundle = await backupService.createBackup({ resources: ['users'] });
|
||||
expect(bundle.servers).toHaveLength(0);
|
||||
expect(bundle.users).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('encrypts sensitive secret values when password provided', async () => {
|
||||
@@ -150,13 +255,22 @@ describe('BackupService', () => {
|
||||
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProjectRepo = mockProjectRepo();
|
||||
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyUserRepo = mockUserRepo();
|
||||
(emptyUserRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyGroupRepo = mockGroupRepo();
|
||||
(emptyGroupRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyRbacRepo = mockRbacRepo();
|
||||
(emptyRbacRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
|
||||
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo, emptyUserRepo, emptyGroupRepo, emptyRbacRepo);
|
||||
const bundle = await service.createBackup();
|
||||
|
||||
expect(bundle.servers).toHaveLength(0);
|
||||
expect(bundle.secrets).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
expect(bundle.users).toHaveLength(0);
|
||||
expect(bundle.groups).toHaveLength(0);
|
||||
expect(bundle.rbacBindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,16 +279,25 @@ describe('RestoreService', () => {
|
||||
let serverRepo: IMcpServerRepository;
|
||||
let secretRepo: ISecretRepository;
|
||||
let projectRepo: IProjectRepository;
|
||||
let userRepo: IUserRepository;
|
||||
let groupRepo: IGroupRepository;
|
||||
let rbacRepo: IRbacDefinitionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
serverRepo = mockServerRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
userRepo = mockUserRepo();
|
||||
groupRepo = mockGroupRepo();
|
||||
rbacRepo = mockRbacRepo();
|
||||
// Default: nothing exists yet
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo);
|
||||
});
|
||||
|
||||
const validBundle = {
|
||||
@@ -187,6 +310,23 @@ describe('RestoreService', () => {
|
||||
projects: [{ name: 'test-proj', description: 'Test' }],
|
||||
};
|
||||
|
||||
const fullBundle = {
|
||||
...validBundle,
|
||||
users: [
|
||||
{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null },
|
||||
{ email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc' },
|
||||
],
|
||||
groups: [
|
||||
{ name: 'dev-team', description: 'Developers', memberEmails: ['alice@test.com', 'bob@test.com'] },
|
||||
],
|
||||
rbacBindings: [
|
||||
{ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] },
|
||||
],
|
||||
projects: [
|
||||
{ name: 'test-proj', description: 'Test', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] },
|
||||
],
|
||||
};
|
||||
|
||||
it('validates valid bundle', () => {
|
||||
expect(restoreService.validateBundle(validBundle)).toBe(true);
|
||||
});
|
||||
@@ -197,6 +337,11 @@ describe('RestoreService', () => {
|
||||
expect(restoreService.validateBundle({ version: '1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('validates old bundles without new fields (backwards compatibility)', () => {
|
||||
expect(restoreService.validateBundle(validBundle)).toBe(true);
|
||||
// Old bundle has no users/groups/rbacBindings — should still validate
|
||||
});
|
||||
|
||||
it('restores all resources', async () => {
|
||||
const result = await restoreService.restore(validBundle);
|
||||
|
||||
@@ -209,6 +354,104 @@ describe('RestoreService', () => {
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores users', async () => {
|
||||
const result = await restoreService.restore(fullBundle);
|
||||
|
||||
expect(result.usersCreated).toBe(2);
|
||||
expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice',
|
||||
role: 'ADMIN',
|
||||
passwordHash: '__RESTORED_MUST_RESET__',
|
||||
}));
|
||||
expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
email: 'bob@test.com',
|
||||
role: 'USER',
|
||||
}));
|
||||
});
|
||||
|
||||
it('restores groups with member resolution', async () => {
|
||||
// After users are created, simulate they can be found by email
|
||||
let callCount = 0;
|
||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
|
||||
// First calls during user restore return null (user doesn't exist yet)
|
||||
// Later calls during group member resolution return the created user
|
||||
callCount++;
|
||||
if (callCount > 2) {
|
||||
// After user creation phase, simulate finding created users
|
||||
if (email === 'alice@test.com') return { id: 'new-u-alice', email };
|
||||
if (email === 'bob@test.com') return { id: 'new-u-bob', email };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await restoreService.restore(fullBundle);
|
||||
|
||||
expect(result.groupsCreated).toBe(1);
|
||||
expect(groupRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'dev-team',
|
||||
description: 'Developers',
|
||||
}));
|
||||
expect(groupRepo.setMembers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores rbac bindings', async () => {
|
||||
const result = await restoreService.restore(fullBundle);
|
||||
|
||||
expect(result.rbacCreated).toBe(1);
|
||||
expect(rbacRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'admins',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}));
|
||||
});
|
||||
|
||||
it('restores enriched projects with server and member linking', async () => {
|
||||
// Simulate servers exist (restored in prior step)
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
// After server restore, we can find them
|
||||
let serverCallCount = 0;
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockImplementation(async (name: string) => {
|
||||
serverCallCount++;
|
||||
// During server restore phase, first call returns null (server doesn't exist)
|
||||
// During project restore phase, server should be found
|
||||
if (serverCallCount > 1 && name === 'github') return { id: 'restored-s1', name: 'github' };
|
||||
return null;
|
||||
});
|
||||
|
||||
// Simulate users exist for member resolution
|
||||
let userCallCount = 0;
|
||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
|
||||
userCallCount++;
|
||||
if (userCallCount > 2 && email === 'alice@test.com') return { id: 'restored-u1', email };
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await restoreService.restore(fullBundle);
|
||||
|
||||
expect(result.projectsCreated).toBe(1);
|
||||
expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'test-proj',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'openai',
|
||||
llmModel: 'gpt-4',
|
||||
}));
|
||||
expect(projectRepo.setServers).toHaveBeenCalled();
|
||||
expect(projectRepo.setMembers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores old bundle without users/groups/rbac', async () => {
|
||||
const result = await restoreService.restore(validBundle);
|
||||
|
||||
expect(result.serversCreated).toBe(1);
|
||||
expect(result.secretsCreated).toBe(1);
|
||||
expect(result.projectsCreated).toBe(1);
|
||||
expect(result.usersCreated).toBe(0);
|
||||
expect(result.groupsCreated).toBe(0);
|
||||
expect(result.rbacCreated).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips existing resources with skip strategy', async () => {
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockServers[0]);
|
||||
const result = await restoreService.restore(validBundle, { conflictStrategy: 'skip' });
|
||||
@@ -218,6 +461,33 @@ describe('RestoreService', () => {
|
||||
expect(serverRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips existing users', async () => {
|
||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(mockUsers[0]);
|
||||
const bundle = { ...validBundle, users: [{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }] };
|
||||
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
|
||||
|
||||
expect(result.usersSkipped).toBe(1);
|
||||
expect(result.usersCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('skips existing groups', async () => {
|
||||
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockGroups[0]);
|
||||
const bundle = { ...validBundle, groups: [{ name: 'dev-team', description: 'Devs', memberEmails: [] }] };
|
||||
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
|
||||
|
||||
expect(result.groupsSkipped).toBe(1);
|
||||
expect(result.groupsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('skips existing rbac bindings', async () => {
|
||||
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockRbacDefinitions[0]);
|
||||
const bundle = { ...validBundle, rbacBindings: [{ name: 'admins', subjects: [], roleBindings: [] }] };
|
||||
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
|
||||
|
||||
expect(result.rbacSkipped).toBe(1);
|
||||
expect(result.rbacCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('aborts on conflict with fail strategy', async () => {
|
||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockServers[0]);
|
||||
const result = await restoreService.restore(validBundle, { conflictStrategy: 'fail' });
|
||||
@@ -233,6 +503,18 @@ describe('RestoreService', () => {
|
||||
expect(serverRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overwrites existing rbac bindings', async () => {
|
||||
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockRbacDefinitions[0]);
|
||||
const bundle = {
|
||||
...validBundle,
|
||||
rbacBindings: [{ name: 'admins', subjects: [{ kind: 'User', name: 'new@test.com' }], roleBindings: [{ role: 'view', resource: 'servers' }] }],
|
||||
};
|
||||
const result = await restoreService.restore(bundle, { conflictStrategy: 'overwrite' });
|
||||
|
||||
expect(result.rbacCreated).toBe(1);
|
||||
expect(rbacRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails restore with encrypted bundle and no password', async () => {
|
||||
const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{}', 'pw') };
|
||||
const result = await restoreService.restore(encBundle);
|
||||
@@ -262,6 +544,26 @@ describe('RestoreService', () => {
|
||||
const result = await restoreService.restore(encBundle, { password: 'wrong' });
|
||||
expect(result.errors[0]).toContain('Failed to decrypt');
|
||||
});
|
||||
|
||||
it('restores in correct order: secrets → servers → users → groups → projects → rbac', async () => {
|
||||
const callOrder: string[] = [];
|
||||
(secretRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('secret'); return { id: 'sec' }; });
|
||||
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
|
||||
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
|
||||
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
|
||||
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [], members: [] }; });
|
||||
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
|
||||
|
||||
await restoreService.restore(fullBundle);
|
||||
|
||||
expect(callOrder[0]).toBe('secret');
|
||||
expect(callOrder[1]).toBe('server');
|
||||
expect(callOrder[2]).toBe('user');
|
||||
expect(callOrder[3]).toBe('user'); // second user
|
||||
expect(callOrder[4]).toBe('group');
|
||||
expect(callOrder[5]).toBe('project');
|
||||
expect(callOrder[6]).toBe('rbac');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup Routes', () => {
|
||||
@@ -272,7 +574,7 @@ describe('Backup Routes', () => {
|
||||
const sRepo = mockServerRepo();
|
||||
const secRepo = mockSecretRepo();
|
||||
const prRepo = mockProjectRepo();
|
||||
backupService = new BackupService(sRepo, prRepo, secRepo);
|
||||
backupService = new BackupService(sRepo, prRepo, secRepo, mockUserRepo(), mockGroupRepo(), mockRbacRepo());
|
||||
|
||||
const rSRepo = mockServerRepo();
|
||||
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
@@ -280,7 +582,13 @@ describe('Backup Routes', () => {
|
||||
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPrRepo = mockProjectRepo();
|
||||
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
|
||||
const rUserRepo = mockUserRepo();
|
||||
(rUserRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rGroupRepo = mockGroupRepo();
|
||||
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rRbacRepo = mockRbacRepo();
|
||||
(rRbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, rUserRepo, rGroupRepo, rRbacRepo);
|
||||
});
|
||||
|
||||
async function buildApp() {
|
||||
@@ -289,7 +597,7 @@ describe('Backup Routes', () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it('POST /api/v1/backup returns bundle', async () => {
|
||||
it('POST /api/v1/backup returns bundle with new resource types', async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -303,6 +611,9 @@ describe('Backup Routes', () => {
|
||||
expect(body.servers).toBeDefined();
|
||||
expect(body.secrets).toBeDefined();
|
||||
expect(body.projects).toBeDefined();
|
||||
expect(body.users).toBeDefined();
|
||||
expect(body.groups).toBeDefined();
|
||||
expect(body.rbacBindings).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/v1/restore imports bundle', async () => {
|
||||
@@ -318,6 +629,9 @@ describe('Backup Routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.serversCreated).toBeDefined();
|
||||
expect(body.usersCreated).toBeDefined();
|
||||
expect(body.groupsCreated).toBeDefined();
|
||||
expect(body.rbacCreated).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/v1/restore rejects invalid bundle', async () => {
|
||||
|
||||
250
src/mcpd/tests/group-service.test.ts
Normal file
250
src/mcpd/tests/group-service.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GroupService } from '../src/services/group.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IGroupRepository, GroupWithMembers } from '../src/repositories/group.repository.js';
|
||||
import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js';
|
||||
import type { Group } from '@prisma/client';
|
||||
|
||||
function makeGroup(overrides: Partial<Group> = {}): Group {
|
||||
return {
|
||||
id: 'grp-1',
|
||||
name: 'developers',
|
||||
description: 'Dev team',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupWithMembers(overrides: Partial<Group> = {}, members: GroupWithMembers['members'] = []): GroupWithMembers {
|
||||
return {
|
||||
...makeGroup(overrides),
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUser(overrides: Partial<SafeUser> = {}): SafeUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
role: 'USER',
|
||||
provider: null,
|
||||
externalId: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockGroupRepo(): IGroupRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeGroup({ name: data.name, description: data.description ?? '' })),
|
||||
update: vi.fn(async (id, data) => makeGroup({ id, description: data.description ?? '' })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
findGroupsForUser: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByEmail: vi.fn(async () => null),
|
||||
create: vi.fn(async () => makeUser()),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GroupService', () => {
|
||||
let groupRepo: ReturnType<typeof mockGroupRepo>;
|
||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
||||
let service: GroupService;
|
||||
|
||||
beforeEach(() => {
|
||||
groupRepo = mockGroupRepo();
|
||||
userRepo = mockUserRepo();
|
||||
service = new GroupService(groupRepo, userRepo);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns empty list', async () => {
|
||||
const result = await service.list();
|
||||
expect(result).toEqual([]);
|
||||
expect(groupRepo.findAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns groups with members', async () => {
|
||||
const groups = [
|
||||
makeGroupWithMembers({ id: 'g1', name: 'admins' }, [
|
||||
{ id: 'gm-1', user: { id: 'u1', email: 'a@b.com', name: 'A' } },
|
||||
]),
|
||||
];
|
||||
vi.mocked(groupRepo.findAll).mockResolvedValue(groups);
|
||||
const result = await service.list();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].members).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a group without members', async () => {
|
||||
const created = makeGroupWithMembers({ name: 'my-group', description: '' }, []);
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({ name: 'my-group' });
|
||||
expect(result.name).toBe('my-group');
|
||||
expect(groupRepo.create).toHaveBeenCalledWith({ name: 'my-group', description: '' });
|
||||
expect(groupRepo.setMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a group with members', async () => {
|
||||
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
|
||||
const bob = makeUser({ id: 'u-bob', email: 'bob@example.com', name: 'Bob' });
|
||||
vi.mocked(userRepo.findByEmail).mockImplementation(async (email: string) => {
|
||||
if (email === 'alice@example.com') return alice;
|
||||
if (email === 'bob@example.com') return bob;
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeGroupWithMembers({ name: 'team' }, [
|
||||
{ id: 'gm-1', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
|
||||
{ id: 'gm-2', user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' } },
|
||||
]);
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({
|
||||
name: 'team',
|
||||
members: ['alice@example.com', 'bob@example.com'],
|
||||
});
|
||||
|
||||
expect(groupRepo.setMembers).toHaveBeenCalledWith('grp-1', ['u-alice', 'u-bob']);
|
||||
expect(result.members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(groupRepo.findByName).mockResolvedValue(makeGroupWithMembers({ name: 'taken' }));
|
||||
await expect(service.create({ name: 'taken' })).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for unknown member email', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
||||
await expect(
|
||||
service.create({ name: 'team', members: ['unknown@example.com'] }),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('validates input', async () => {
|
||||
await expect(service.create({ name: '' })).rejects.toThrow();
|
||||
await expect(service.create({ name: 'UPPERCASE' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns group when found', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1' });
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
const result = await service.getById('g1');
|
||||
expect(result.id).toBe('g1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByName', () => {
|
||||
it('returns group when found', async () => {
|
||||
const group = makeGroupWithMembers({ name: 'admins' });
|
||||
vi.mocked(groupRepo.findByName).mockResolvedValue(group);
|
||||
const result = await service.getByName('admins');
|
||||
expect(result.name).toBe('admins');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates description', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1' });
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
|
||||
const updated = makeGroupWithMembers({ id: 'g1', description: 'new desc' });
|
||||
// After update, getById is called again to return fresh data
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('g1', { description: 'new desc' });
|
||||
expect(groupRepo.update).toHaveBeenCalledWith('g1', { description: 'new desc' });
|
||||
expect(result.description).toBe('new desc');
|
||||
});
|
||||
|
||||
it('updates members (full replacement)', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1' }, [
|
||||
{ id: 'gm-1', user: { id: 'u-old', email: 'old@example.com', name: 'Old' } },
|
||||
]);
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
|
||||
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(alice);
|
||||
|
||||
const updated = makeGroupWithMembers({ id: 'g1' }, [
|
||||
{ id: 'gm-2', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
|
||||
]);
|
||||
vi.mocked(groupRepo.findById).mockResolvedValueOnce(group).mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('g1', { members: ['alice@example.com'] });
|
||||
expect(groupRepo.setMembers).toHaveBeenCalledWith('g1', ['u-alice']);
|
||||
expect(result.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when group not found', async () => {
|
||||
await expect(service.update('missing', { description: 'x' })).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for unknown member email on update', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1' });
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('g1', { members: ['unknown@example.com'] }),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes group', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1' });
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
await service.delete('g1');
|
||||
expect(groupRepo.delete).toHaveBeenCalledWith('g1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when group not found', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group includes resolved member info', () => {
|
||||
it('members include user id, email, and name', async () => {
|
||||
const group = makeGroupWithMembers({ id: 'g1', name: 'team' }, [
|
||||
{ id: 'gm-1', user: { id: 'u1', email: 'alice@example.com', name: 'Alice' } },
|
||||
{ id: 'gm-2', user: { id: 'u2', email: 'bob@example.com', name: null } },
|
||||
]);
|
||||
vi.mocked(groupRepo.findById).mockResolvedValue(group);
|
||||
|
||||
const result = await service.getById('g1');
|
||||
expect(result.members[0].user).toEqual({ id: 'u1', email: 'alice@example.com', name: 'Alice' });
|
||||
expect(result.members[1].user).toEqual({ id: 'u2', email: 'bob@example.com', name: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,17 @@ function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
env: [],
|
||||
healthCheck: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
templateName: null,
|
||||
templateVersion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +32,7 @@ describe('generateMcpConfig', () => {
|
||||
expect(result).toEqual({ mcpServers: {} });
|
||||
});
|
||||
|
||||
it('generates config for a single server', () => {
|
||||
it('generates config for a single STDIO server', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ server: makeServer(), resolvedEnv: {} },
|
||||
]);
|
||||
@@ -34,7 +41,7 @@ describe('generateMcpConfig', () => {
|
||||
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
|
||||
});
|
||||
|
||||
it('includes resolved env when present', () => {
|
||||
it('includes resolved env when present for STDIO server', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
|
||||
]);
|
||||
@@ -67,4 +74,35 @@ describe('generateMcpConfig', () => {
|
||||
]);
|
||||
expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
|
||||
});
|
||||
|
||||
it('generates URL-based config for SSE servers', () => {
|
||||
const server = makeServer({ name: 'sse-server', transport: 'SSE' });
|
||||
const result = generateMcpConfig([
|
||||
{ server, resolvedEnv: { TOKEN: 'abc' } },
|
||||
]);
|
||||
const config = result.mcpServers['sse-server'];
|
||||
expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
|
||||
expect(config?.command).toBeUndefined();
|
||||
expect(config?.args).toBeUndefined();
|
||||
expect(config?.env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates URL-based config for STREAMABLE_HTTP servers', () => {
|
||||
const server = makeServer({ name: 'stream-server', transport: 'STREAMABLE_HTTP' });
|
||||
const result = generateMcpConfig([
|
||||
{ server, resolvedEnv: {} },
|
||||
]);
|
||||
const config = result.mcpServers['stream-server'];
|
||||
expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/stream-server');
|
||||
expect(config?.command).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mixes STDIO and SSE servers correctly', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ server: makeServer({ name: 'stdio-srv', transport: 'STDIO' }), resolvedEnv: {} },
|
||||
{ server: makeServer({ name: 'sse-srv', transport: 'SSE' }), resolvedEnv: {} },
|
||||
]);
|
||||
expect(result.mcpServers['stdio-srv']?.command).toBe('npx');
|
||||
expect(result.mcpServers['sse-srv']?.url).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,403 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProjectService } from '../src/services/project.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||
return {
|
||||
id: 'proj-1',
|
||||
name: 'test-project',
|
||||
description: '',
|
||||
ownerId: 'user-1',
|
||||
proxyMode: 'direct',
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [],
|
||||
members: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
name: 'test-server',
|
||||
description: '',
|
||||
packageName: '@mcp/test',
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
env: [],
|
||||
healthCheck: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
templateName: null,
|
||||
templateVersion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'proj-1',
|
||||
create: vi.fn(async (data) => makeProject({
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
proxyMode: data.proxyMode,
|
||||
llmProvider: data.llmProvider ?? null,
|
||||
llmModel: data.llmModel ?? null,
|
||||
})),
|
||||
update: vi.fn(async (id) => ({
|
||||
id, name: 'test', description: '', ownerId: 'u1', version: 2,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setServers: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockServerRepo(): IMcpServerRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => makeServer()),
|
||||
update: vi.fn(async () => makeServer()),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSecretRepo(): ISecretRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
||||
update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByEmail: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({
|
||||
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepo = mockProjectRepo();
|
||||
service = new ProjectService(projectRepo);
|
||||
serverRepo = mockServerRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
userRepo = mockUserRepo();
|
||||
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a project', async () => {
|
||||
it('creates a basic project', async () => {
|
||||
// After create, getById is called to re-fetch with relations
|
||||
const created = makeProject({ name: 'my-project', ownerId: 'user-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({ name: 'my-project' }, 'user-1');
|
||||
expect(result.name).toBe('my-project');
|
||||
expect(result.ownerId).toBe('user-1');
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject());
|
||||
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('validates input', async () => {
|
||||
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates project with servers (resolves names)', async () => {
|
||||
const srv1 = makeServer({ id: 'srv-1', name: 'github' });
|
||||
const srv2 = makeServer({ id: 'srv-2', name: 'slack' });
|
||||
vi.mocked(serverRepo.findByName).mockImplementation(async (name) => {
|
||||
if (name === 'github') return srv1;
|
||||
if (name === 'slack') return srv2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeProject({ id: 'proj-new' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
||||
id: 'proj-new',
|
||||
servers: [
|
||||
{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } },
|
||||
{ id: 'ps-2', server: { id: 'srv-2', name: 'slack' } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1');
|
||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']);
|
||||
expect(result.servers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates project with members (resolves emails)', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
|
||||
if (email === 'alice@test.com') {
|
||||
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeProject({ id: 'proj-new' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
||||
id: 'proj-new',
|
||||
members: [
|
||||
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await service.create({
|
||||
name: 'my-project',
|
||||
members: ['alice@test.com'],
|
||||
}, 'user-1');
|
||||
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
|
||||
expect(result.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates project with proxyMode and llmProvider', async () => {
|
||||
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({
|
||||
name: 'filtered-proj',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'openai',
|
||||
}, 'user-1');
|
||||
|
||||
expect(result.proxyMode).toBe('filtered');
|
||||
expect(result.llmProvider).toBe('openai');
|
||||
});
|
||||
|
||||
it('rejects filtered project without llmProvider', async () => {
|
||||
await expect(
|
||||
service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when server name resolution fails', async () => {
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when member email resolution fails', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'my-project',
|
||||
members: ['nobody@test.com'],
|
||||
}, 'user-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('returns project when found', async () => {
|
||||
const proj = makeProject({ id: 'found' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
||||
const result = await service.getById('found');
|
||||
expect(result.id).toBe('found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndGet', () => {
|
||||
it('finds by ID first', async () => {
|
||||
const proj = makeProject({ id: 'proj-id' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
||||
const result = await service.resolveAndGet('proj-id');
|
||||
expect(result.id).toBe('proj-id');
|
||||
});
|
||||
|
||||
it('falls back to name when ID not found', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
||||
const proj = makeProject({ name: 'my-name' });
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
|
||||
const result = await service.resolveAndGet('my-name');
|
||||
expect(result.name).toBe('my-name');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when neither ID nor name found', async () => {
|
||||
await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates servers (full replacement)', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
const srv = makeServer({ id: 'srv-new', name: 'new-srv' });
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
||||
|
||||
await service.update('proj-1', { servers: ['new-srv'] });
|
||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
||||
});
|
||||
|
||||
it('updates members (full replacement)', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue({
|
||||
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.update('proj-1', { members: ['bob@test.com'] });
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
|
||||
});
|
||||
|
||||
it('updates proxyMode', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' });
|
||||
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'anthropic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes project', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await service.delete('p1');
|
||||
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when project does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMcpConfig', () => {
|
||||
it('generates direct mode config with STDIO servers', async () => {
|
||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'my-proj',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['github']).toBeDefined();
|
||||
expect(config.mcpServers['github']?.command).toBe('npx');
|
||||
expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']);
|
||||
});
|
||||
|
||||
it('generates direct mode config with SSE servers (URL-based)', async () => {
|
||||
const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' });
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
|
||||
expect(config.mcpServers['sse-server']?.command).toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates filtered mode config (single mcplocal entry)', async () => {
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'filtered-proj',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'openai',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
||||
expect(config.mcpServers['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj');
|
||||
});
|
||||
|
||||
it('resolves by name for mcp-config', async () => {
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'my-proj',
|
||||
proxyMode: 'direct',
|
||||
servers: [],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
||||
|
||||
const config = await service.generateMcpConfig('my-proj');
|
||||
expect(config.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('includes env for STDIO servers', async () => {
|
||||
const srv = makeServer({
|
||||
id: 'srv-1',
|
||||
name: 'github',
|
||||
transport: 'STDIO',
|
||||
env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }],
|
||||
});
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
229
src/mcpd/tests/rbac-definition-service.test.ts
Normal file
229
src/mcpd/tests/rbac-definition-service.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
import type { RbacDefinition } from '@prisma/client';
|
||||
|
||||
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
|
||||
return {
|
||||
id: 'def-1',
|
||||
name: 'test-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(): IRbacDefinitionRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeDef({ name: data.name, subjects: data.subjects, roleBindings: data.roleBindings })),
|
||||
update: vi.fn(async (id, data) => makeDef({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RbacDefinitionService', () => {
|
||||
let repo: ReturnType<typeof mockRepo>;
|
||||
let service: RbacDefinitionService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mockRepo();
|
||||
service = new RbacDefinitionService(repo);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all definitions', async () => {
|
||||
const defs = await service.list();
|
||||
expect(repo.findAll).toHaveBeenCalled();
|
||||
expect(defs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns definition when found', async () => {
|
||||
const def = makeDef();
|
||||
vi.mocked(repo.findById).mockResolvedValue(def);
|
||||
const result = await service.getById('def-1');
|
||||
expect(result.id).toBe('def-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByName', () => {
|
||||
it('returns definition when found', async () => {
|
||||
const def = makeDef();
|
||||
vi.mocked(repo.findByName).mockResolvedValue(def);
|
||||
const result = await service.getByName('test-rbac');
|
||||
expect(result.name).toBe('test-rbac');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a definition with valid input', async () => {
|
||||
const result = await service.create({
|
||||
name: 'new-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
expect(result.name).toBe('new-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(repo.findByName).mockResolvedValue(makeDef());
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'test-rbac',
|
||||
subjects: [{ kind: 'User', name: 'bob@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws on missing subjects', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing roleBindings', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid role', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'superadmin', resource: '*' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid subject kind', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'Robot', name: 'bot-1' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid name format', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'Invalid Name!',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('normalizes singular resource names to plural', async () => {
|
||||
await service.create({
|
||||
name: 'singular-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'server' },
|
||||
{ role: 'edit', resource: 'secret', name: 'my-secret' },
|
||||
],
|
||||
});
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[1]!.resource).toBe('secrets');
|
||||
expect(call.roleBindings[1]!.name).toBe('my-secret');
|
||||
});
|
||||
|
||||
it('creates a definition with operation bindings', async () => {
|
||||
const result = await service.create({
|
||||
name: 'ops-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'run', action: 'logs' }],
|
||||
});
|
||||
expect(result.name).toBe('ops-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.action).toBe('logs');
|
||||
});
|
||||
|
||||
it('creates a definition with mixed resource and operation bindings', async () => {
|
||||
const result = await service.create({
|
||||
name: 'mixed-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
});
|
||||
expect(result.name).toBe('mixed-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings).toHaveLength(2);
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[1]!.action).toBe('logs');
|
||||
});
|
||||
|
||||
it('creates a definition with name-scoped resource binding', async () => {
|
||||
const result = await service.create({
|
||||
name: 'scoped-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
||||
});
|
||||
expect(result.name).toBe('scoped-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[0]!.name).toBe('my-ha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates an existing definition', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeDef());
|
||||
await service.update('def-1', { subjects: [{ kind: 'User', name: 'bob@example.com' }] });
|
||||
expect(repo.update).toHaveBeenCalledWith('def-1', {
|
||||
subjects: [{ kind: 'User', name: 'bob@example.com' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError when definition does not exist', async () => {
|
||||
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes an existing definition', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeDef());
|
||||
await service.delete('def-1');
|
||||
expect(repo.delete).toHaveBeenCalledWith('def-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when definition does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
683
src/mcpd/tests/rbac.test.ts
Normal file
683
src/mcpd/tests/rbac.test.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RbacService } from '../src/services/rbac.service.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
import type { RbacDefinition, PrismaClient } from '@prisma/client';
|
||||
|
||||
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
|
||||
return {
|
||||
id: 'def-1',
|
||||
name: 'test-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(definitions: RbacDefinition[] = []): IRbacDefinitionRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => definitions),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => makeDef()),
|
||||
update: vi.fn(async () => makeDef()),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface MockPrisma {
|
||||
user: { findUnique: ReturnType<typeof vi.fn> };
|
||||
groupMember: { findMany: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function mockPrisma(overrides?: Partial<MockPrisma>): PrismaClient {
|
||||
return {
|
||||
user: {
|
||||
findUnique: overrides?.user?.findUnique ?? vi.fn(async () => null),
|
||||
},
|
||||
groupMember: {
|
||||
findMany: overrides?.groupMember?.findMany ?? vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe('RbacService', () => {
|
||||
describe('canAccess — edit:* (wildcard resource)', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can view servers', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('can edit users', async () => {
|
||||
expect(await service.canAccess('user-1', 'edit', 'users')).toBe(true);
|
||||
});
|
||||
|
||||
it('can create resources (edit includes create)', async () => {
|
||||
expect(await service.canAccess('user-1', 'create', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('can delete resources (edit includes delete)', async () => {
|
||||
expect(await service.canAccess('user-1', 'delete', 'secrets')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot run resources (edit does not include run)', async () => {
|
||||
expect(await service.canAccess('user-1', 'run', 'projects')).toBe(false);
|
||||
});
|
||||
|
||||
it('can edit any resource (wildcard)', async () => {
|
||||
expect(await service.canAccess('user-1', 'edit', 'secrets')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'edit', 'projects')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'edit', 'instances')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — edit:servers', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'bob@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'bob@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can view servers', async () => {
|
||||
expect(await service.canAccess('user-2', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('can edit servers', async () => {
|
||||
expect(await service.canAccess('user-2', 'edit', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('can create servers (edit includes create)', async () => {
|
||||
expect(await service.canAccess('user-2', 'create', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('can delete servers (edit includes delete)', async () => {
|
||||
expect(await service.canAccess('user-2', 'delete', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot edit users (wrong resource)', async () => {
|
||||
expect(await service.canAccess('user-2', 'edit', 'users')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — view:servers', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'carol@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'carol@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can view servers', async () => {
|
||||
expect(await service.canAccess('user-3', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot edit servers', async () => {
|
||||
expect(await service.canAccess('user-3', 'edit', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot create servers', async () => {
|
||||
expect(await service.canAccess('user-3', 'create', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot delete servers', async () => {
|
||||
expect(await service.canAccess('user-3', 'delete', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — create role', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'dan@example.com' }],
|
||||
roleBindings: [{ role: 'create', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'dan@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can create servers', async () => {
|
||||
expect(await service.canAccess('user-d', 'create', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot view servers', async () => {
|
||||
expect(await service.canAccess('user-d', 'view', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot delete servers', async () => {
|
||||
expect(await service.canAccess('user-d', 'delete', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot edit servers', async () => {
|
||||
expect(await service.canAccess('user-d', 'edit', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — delete role', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'eve@example.com' }],
|
||||
roleBindings: [{ role: 'delete', resource: 'secrets' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can delete secrets', async () => {
|
||||
expect(await service.canAccess('user-e', 'delete', 'secrets')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot create secrets', async () => {
|
||||
expect(await service.canAccess('user-e', 'create', 'secrets')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot view secrets', async () => {
|
||||
expect(await service.canAccess('user-e', 'view', 'secrets')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — run role on resource', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'frank@example.com' }],
|
||||
roleBindings: [{ role: 'run', resource: 'projects' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can run projects', async () => {
|
||||
expect(await service.canAccess('user-f', 'run', 'projects')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot view projects (run does not include view)', async () => {
|
||||
expect(await service.canAccess('user-f', 'view', 'projects')).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot run servers (wrong resource)', async () => {
|
||||
expect(await service.canAccess('user-f', 'run', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — no matching binding', () => {
|
||||
it('returns false when user has no matching definitions', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'other@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'nobody@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-x', 'view', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when user does not exist', async () => {
|
||||
const repo = mockRepo([makeDef()]);
|
||||
const prisma = mockPrisma(); // user.findUnique returns null
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('nonexistent', 'view', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — empty subjects', () => {
|
||||
it('matches nobody when subjects is empty', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — group membership', () => {
|
||||
it('grants access through group subject', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'Group', name: 'devs' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'dave@example.com' })) },
|
||||
groupMember: {
|
||||
findMany: vi.fn(async () => [{ group: { name: 'devs' } }]),
|
||||
},
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-4', 'view', 'servers')).toBe(true);
|
||||
expect(await service.canAccess('user-4', 'edit', 'servers')).toBe(true);
|
||||
expect(await service.canAccess('user-4', 'run', 'servers')).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when user is not in the group', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'Group', name: 'devs' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) },
|
||||
groupMember: {
|
||||
findMany: vi.fn(async () => [{ group: { name: 'ops' } }]),
|
||||
},
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-5', 'view', 'servers')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — multiple definitions (union)', () => {
|
||||
it('unions permissions from multiple matching definitions', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
id: 'def-1',
|
||||
name: 'rbac-viewers',
|
||||
subjects: [{ kind: 'User', name: 'frank@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
makeDef({
|
||||
id: 'def-2',
|
||||
name: 'rbac-editors',
|
||||
subjects: [{ kind: 'User', name: 'frank@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'secrets' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
// From def-1: view on servers
|
||||
expect(await service.canAccess('user-6', 'view', 'servers')).toBe(true);
|
||||
expect(await service.canAccess('user-6', 'edit', 'servers')).toBe(false);
|
||||
|
||||
// From def-2: edit on secrets (includes view, create, delete)
|
||||
expect(await service.canAccess('user-6', 'view', 'secrets')).toBe(true);
|
||||
expect(await service.canAccess('user-6', 'edit', 'secrets')).toBe(true);
|
||||
expect(await service.canAccess('user-6', 'create', 'secrets')).toBe(true);
|
||||
|
||||
// No permission on other resources
|
||||
expect(await service.canAccess('user-6', 'view', 'users')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — mixed user and group subjects', () => {
|
||||
it('matches on either user or group subject', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [
|
||||
{ kind: 'User', name: 'grace@example.com' },
|
||||
{ kind: 'Group', name: 'admins' },
|
||||
],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Test user match (not in group)
|
||||
const prismaUser = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'grace@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const serviceUser = new RbacService(repo, prismaUser);
|
||||
expect(await serviceUser.canAccess('user-7', 'edit', 'servers')).toBe(true);
|
||||
|
||||
// Test group match (different email)
|
||||
const prismaGroup = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'hank@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admins' } }]) },
|
||||
});
|
||||
const serviceGroup = new RbacService(repo, prismaGroup);
|
||||
expect(await serviceGroup.canAccess('user-8', 'edit', 'servers')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — singular resource names', () => {
|
||||
it('normalizes singular resource in binding to match plural check', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'server' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes singular resource in check to match plural binding', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canAccess('user-1', 'edit', 'server')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'view', 'instance')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — name-scoped resource bindings', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('allows access to the named resource', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers', 'my-ha')).toBe(true);
|
||||
});
|
||||
|
||||
it('denies access to a different named resource', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers', 'other-server')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows listing (no resourceName specified)', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccess — unnamed binding matches any resourceName', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('allows access to any named resource', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers', 'any-server')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows listing', async () => {
|
||||
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRunOperation', () => {
|
||||
it('grants operation when run:action binding matches', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'run', action: 'logs' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canRunOperation('user-1', 'logs')).toBe(true);
|
||||
});
|
||||
|
||||
it('denies operation when action does not match', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'run', action: 'logs' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canRunOperation('user-1', 'backup')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores resource bindings (only checks operation bindings)', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
expect(await service.canRunOperation('user-1', 'logs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed resource + operation bindings', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', resource: '*' },
|
||||
{ role: 'run', action: 'impersonate' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'admin@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admin' } }]) },
|
||||
});
|
||||
service = new RbacService(repo, prisma);
|
||||
});
|
||||
|
||||
it('can access resources', async () => {
|
||||
expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'view', 'users')).toBe(true);
|
||||
expect(await service.canAccess('user-1', 'run', 'projects')).toBe(true);
|
||||
});
|
||||
|
||||
it('can run operations', async () => {
|
||||
expect(await service.canRunOperation('user-1', 'impersonate')).toBe(true);
|
||||
expect(await service.canRunOperation('user-1', 'logs')).toBe(true);
|
||||
expect(await service.canRunOperation('user-1', 'backup')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot run undefined operations', async () => {
|
||||
expect(await service.canRunOperation('user-1', 'destroy-all')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPermissions', () => {
|
||||
it('returns all permissions for a user', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'view', resource: 'secrets' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const perms = await service.getPermissions('user-1');
|
||||
expect(perms).toEqual([
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'view', resource: 'secrets' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns mixed resource and operation permissions', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const perms = await service.getPermissions('user-1');
|
||||
expect(perms).toEqual([
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes name field in name-scoped permissions', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const perms = await service.getPermissions('user-1');
|
||||
expect(perms).toEqual([
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty for unknown user', async () => {
|
||||
const repo = mockRepo([makeDef()]);
|
||||
const prisma = mockPrisma();
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const perms = await service.getPermissions('nonexistent');
|
||||
expect(perms).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty when no definitions match', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'other@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const perms = await service.getPermissions('user-1');
|
||||
expect(perms).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
208
src/mcpd/tests/user-service.test.ts
Normal file
208
src/mcpd/tests/user-service.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { UserService } from '../src/services/user.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js';
|
||||
|
||||
function makeSafeUser(overrides: Partial<SafeUser> = {}): SafeUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
role: 'USER',
|
||||
provider: null,
|
||||
externalId: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByEmail: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) =>
|
||||
makeSafeUser({ email: data.email, name: data.name ?? null }),
|
||||
),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserService', () => {
|
||||
let repo: ReturnType<typeof mockUserRepo>;
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mockUserRepo();
|
||||
service = new UserService(repo);
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────────
|
||||
|
||||
describe('list', () => {
|
||||
it('returns empty array when no users', async () => {
|
||||
const result = await service.list();
|
||||
expect(result).toEqual([]);
|
||||
expect(repo.findAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns all users', async () => {
|
||||
const users = [
|
||||
makeSafeUser({ id: 'u1', email: 'a@b.com' }),
|
||||
makeSafeUser({ id: 'u2', email: 'c@d.com' }),
|
||||
];
|
||||
vi.mocked(repo.findAll).mockResolvedValue(users);
|
||||
|
||||
const result = await service.list();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.email).toBe('a@b.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────────
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a user and hashes password', async () => {
|
||||
const result = await service.create({
|
||||
email: 'alice@example.com',
|
||||
password: 'securePass123',
|
||||
});
|
||||
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(repo.create).toHaveBeenCalledOnce();
|
||||
|
||||
// Verify the passwordHash was generated (not the plain password)
|
||||
const createCall = vi.mocked(repo.create).mock.calls[0]![0]!;
|
||||
expect(createCall.passwordHash).toBeDefined();
|
||||
expect(createCall.passwordHash).not.toBe('securePass123');
|
||||
expect(createCall.passwordHash.startsWith('$2b$')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a user with optional name', async () => {
|
||||
await service.create({
|
||||
email: 'bob@example.com',
|
||||
password: 'securePass123',
|
||||
name: 'Bob',
|
||||
});
|
||||
|
||||
const createCall = vi.mocked(repo.create).mock.calls[0]![0]!;
|
||||
expect(createCall.email).toBe('bob@example.com');
|
||||
expect(createCall.name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('returns user without passwordHash', async () => {
|
||||
const result = await service.create({
|
||||
email: 'alice@example.com',
|
||||
password: 'securePass123',
|
||||
});
|
||||
|
||||
// SafeUser type should not have passwordHash
|
||||
expect(result).not.toHaveProperty('passwordHash');
|
||||
});
|
||||
|
||||
it('throws ConflictError when email already exists', async () => {
|
||||
vi.mocked(repo.findByEmail).mockResolvedValue(makeSafeUser());
|
||||
|
||||
await expect(
|
||||
service.create({ email: 'alice@example.com', password: 'securePass123' }),
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws ZodError for invalid email', async () => {
|
||||
await expect(
|
||||
service.create({ email: 'not-an-email', password: 'securePass123' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ZodError for short password', async () => {
|
||||
await expect(
|
||||
service.create({ email: 'a@b.com', password: 'short' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ZodError for missing email', async () => {
|
||||
await expect(
|
||||
service.create({ password: 'securePass123' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ZodError for password exceeding max length', async () => {
|
||||
await expect(
|
||||
service.create({ email: 'a@b.com', password: 'x'.repeat(129) }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getById ───────────────────────────────────────────────
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns user when found', async () => {
|
||||
const user = makeSafeUser();
|
||||
vi.mocked(repo.findById).mockResolvedValue(user);
|
||||
|
||||
const result = await service.getById('user-1');
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(repo.findById).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getByEmail ────────────────────────────────────────────
|
||||
|
||||
describe('getByEmail', () => {
|
||||
it('returns user when found', async () => {
|
||||
const user = makeSafeUser();
|
||||
vi.mocked(repo.findByEmail).mockResolvedValue(user);
|
||||
|
||||
const result = await service.getByEmail('alice@example.com');
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(repo.findByEmail).toHaveBeenCalledWith('alice@example.com');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getByEmail('nobody@example.com')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete ────────────────────────────────────────────────
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes user by id', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeSafeUser());
|
||||
|
||||
await service.delete('user-1');
|
||||
expect(repo.delete).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when user does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── count ─────────────────────────────────────────────────
|
||||
|
||||
describe('count', () => {
|
||||
it('returns 0 when no users', async () => {
|
||||
const result = await service.count();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 when one user exists', async () => {
|
||||
vi.mocked(repo.count).mockResolvedValue(1);
|
||||
const result = await service.count();
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('returns correct count for multiple users', async () => {
|
||||
vi.mocked(repo.count).mockResolvedValue(5);
|
||||
const result = await service.count();
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user