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:
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