Files
mcpctl/src/mcpd/tests/auth-bootstrap.test.ts
Michal dcda93d179
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
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>
2026-02-23 11:05:19 +00:00

425 lines
14 KiB
TypeScript

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