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 { 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; 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); }); }); });