- 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>
251 lines
9.1 KiB
TypeScript
251 lines
9.1 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|