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:
172
src/mcplocal/tests/project-mcp-endpoint.test.ts
Normal file
172
src/mcplocal/tests/project-mcp-endpoint.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
|
||||
|
||||
// Mock discovery module — we don't want real HTTP calls
|
||||
vi.mock('../src/discovery.js', () => ({
|
||||
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
||||
}));
|
||||
|
||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||
|
||||
function mockMcpdClient() {
|
||||
return {
|
||||
baseUrl: 'http://test:3100',
|
||||
token: 'test-token',
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(async () => ({ status: 200, body: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('registerProjectMcpEndpoint', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = Fastify();
|
||||
registerProjectMcpEndpoint(app, mockMcpdClient() as any);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
it('registers POST /projects/:projectName/mcp route', async () => {
|
||||
// The endpoint should exist and attempt to handle MCP protocol
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// The StreamableHTTPServerTransport hijacks the response,
|
||||
// so we may get a 200 or the transport handles it directly
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('calls refreshProjectUpstreams with project name', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
||||
expect.any(Object), // McpRouter instance
|
||||
expect.any(Object), // McpdClient
|
||||
'smart-home',
|
||||
undefined, // no auth token
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards auth token from Authorization header', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/secure-project/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'authorization': 'Bearer my-token-123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
'secure-project',
|
||||
'my-token-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 502 when project discovery fails', async () => {
|
||||
vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden'));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/bad-project/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.json().error).toContain('Failed to load project');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session ID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'nonexistent-session',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for GET without session', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/projects/smart-home/mcp',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('session');
|
||||
});
|
||||
|
||||
it('returns 400 for DELETE without session', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/projects/smart-home/mcp',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('session');
|
||||
});
|
||||
|
||||
it('caches project router across requests', async () => {
|
||||
// Two requests to the same project should reuse the router
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/smart-home/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// refreshProjectUpstreams should only be called once (cached)
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates separate routers for different projects', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/project-a/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/projects/project-b/mcp',
|
||||
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
// Two different projects should trigger two refreshes
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2);
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined);
|
||||
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user