import { describe, it, expect, vi } from 'vitest'; import { createDescribeCommand } from '../../src/commands/describe.js'; import type { DescribeCommandDeps } from '../../src/commands/describe.js'; import type { ApiClient } from '../../src/api-client.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => []), post: vi.fn(async () => ({})), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { const output: string[] = []; return { output, client: mockClient(), fetchResource: vi.fn(async () => item), log: (...args: string[]) => output.push(args.join(' ')), }; } describe('describe command', () => { it('shows detailed server info with sections', async () => { const deps = makeDeps({ id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null, env: [], createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); const text = deps.output.join('\n'); expect(text).toContain('=== Server: slack ==='); expect(text).toContain('Name:'); expect(text).toContain('slack'); expect(text).toContain('Transport:'); expect(text).toContain('STDIO'); expect(text).toContain('Package:'); expect(text).toContain('@slack/mcp'); expect(text).toContain('Metadata:'); expect(text).toContain('ID:'); }); it('resolves resource aliases', async () => { const deps = makeDeps({ id: 's1' }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'sec', 's1']); expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1'); }); it('outputs JSON format', async () => { const deps = makeDeps({ id: 'srv-1', name: 'slack' }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'json']); const parsed = JSON.parse(deps.output[0] ?? ''); expect(parsed.name).toBe('slack'); }); it('outputs YAML format', async () => { const deps = makeDeps({ id: 'srv-1', name: 'slack' }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'yaml']); expect(deps.output[0]).toContain('name: slack'); }); it('shows project detail', async () => { const deps = makeDeps({ id: 'proj-1', name: 'my-project', description: 'A test project', ownerId: 'user-1', createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'project', 'proj-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Project: my-project ==='); expect(text).toContain('A test project'); expect(text).toContain('user-1'); }); it('shows secret detail with masked values', async () => { const deps = makeDeps({ id: 'sec-1', name: 'ha-creds', data: { TOKEN: 'abc123', URL: 'https://ha.local' }, createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Secret: ha-creds ==='); expect(text).toContain('TOKEN'); expect(text).toContain('***'); expect(text).not.toContain('abc123'); expect(text).toContain('use --show-values to reveal'); }); it('shows secret detail with revealed values when --show-values', async () => { const deps = makeDeps({ id: 'sec-1', name: 'ha-creds', data: { TOKEN: 'abc123' }, createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']); const text = deps.output.join('\n'); expect(text).toContain('abc123'); expect(text).not.toContain('***'); }); it('shows instance detail with container info', async () => { const deps = makeDeps({ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123', port: 3000, createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Instance: inst-1 ==='); expect(text).toContain('RUNNING'); expect(text).toContain('abc123'); }); it('resolves server name to instance for describe instance', async () => { const deps = makeDeps({ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123', port: 3000, }); // resolveNameOrId will throw (not a CUID, name won't match instances) vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // instances list (no name match) .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list .mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING' }] as never); // instances for server const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'instance', 'my-grafana']); expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-1'); }); it('resolves server name and picks running instance over stopped', async () => { const deps = makeDeps({ id: 'inst-2', serverId: 'srv-1', server: { name: 'my-ha' }, status: 'RUNNING', containerId: 'def456', }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // instances list .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-ha' }] as never) .mockResolvedValueOnce([ { id: 'inst-1', status: 'ERROR' }, { id: 'inst-2', status: 'RUNNING' }, ] as never); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'instance', 'my-ha']); expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-2'); }); it('throws when no instances found for server name', async () => { const deps = makeDeps(); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // instances list .mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never) .mockResolvedValueOnce([] as never); // no instances const cmd = createDescribeCommand(deps); await expect(cmd.parseAsync(['node', 'test', 'instance', 'my-server'])).rejects.toThrow( /No instances found/, ); }); it('shows instance with server name in header', async () => { const deps = makeDeps({ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123', port: 3000, }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Instance: my-grafana ==='); }); it('shows instance health and events', async () => { const deps = makeDeps({ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123', healthStatus: 'healthy', lastHealthCheck: '2025-01-15T10:30:00Z', events: [ { timestamp: '2025-01-15T10:30:00Z', type: 'Normal', message: 'Health check passed (45ms)' }, ], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); const text = deps.output.join('\n'); expect(text).toContain('Health:'); expect(text).toContain('healthy'); expect(text).toContain('Events:'); expect(text).toContain('Health check passed'); }); it('shows server healthCheck section', async () => { const deps = makeDeps({ id: 'srv-1', name: 'my-grafana', transport: 'STDIO', healthCheck: { tool: 'list_datasources', arguments: {}, intervalSeconds: 60, timeoutSeconds: 10, failureThreshold: 3, }, }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); const text = deps.output.join('\n'); expect(text).toContain('Health Check:'); expect(text).toContain('list_datasources'); expect(text).toContain('60s'); expect(text).toContain('Failure Threshold:'); }); it('shows template detail with healthCheck and usage', async () => { const deps = makeDeps({ id: 'tpl-1', name: 'grafana', transport: 'STDIO', version: '1.0.0', packageName: '@leval/mcp-grafana', env: [ { name: 'GRAFANA_URL', required: true, description: 'Grafana instance URL' }, ], healthCheck: { tool: 'list_datasources', arguments: {}, intervalSeconds: 60, timeoutSeconds: 10, failureThreshold: 3, }, }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'template', 'tpl-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Template: grafana ==='); expect(text).toContain('@leval/mcp-grafana'); expect(text).toContain('GRAFANA_URL'); expect(text).toContain('Health Check:'); expect(text).toContain('list_datasources'); expect(text).toContain('mcpctl create server my-grafana --from-template=grafana'); }); it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => { const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice Smith', provider: null, createdAt: '2025-01-01', updatedAt: '2025-01-15', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1'); const text = deps.output.join('\n'); expect(text).toContain('=== User: alice@test.com ==='); expect(text).toContain('Email:'); expect(text).toContain('alice@test.com'); expect(text).toContain('Name:'); expect(text).toContain('Alice Smith'); expect(text).not.toContain('Role:'); expect(text).toContain('Provider:'); expect(text).toContain('local'); expect(text).toContain('ID:'); expect(text).toContain('usr-1'); }); it('shows user with no name as dash', async () => { const deps = makeDeps({ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-2']); const text = deps.output.join('\n'); expect(text).toContain('=== User: bob@test.com ==='); expect(text).toContain('Name:'); expect(text).toContain('-'); expect(text).not.toContain('Role:'); expect(text).toContain('oidc'); }); it('shows group detail with members', async () => { const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Development team', members: [ { user: { email: 'alice@test.com' }, createdAt: '2025-01-01' }, { user: { email: 'bob@test.com' }, createdAt: '2025-01-02' }, ], createdAt: '2025-01-01', updatedAt: '2025-01-15', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1'); const text = deps.output.join('\n'); expect(text).toContain('=== Group: dev-team ==='); expect(text).toContain('Name:'); expect(text).toContain('dev-team'); expect(text).toContain('Description:'); expect(text).toContain('Development team'); expect(text).toContain('Members:'); expect(text).toContain('EMAIL'); expect(text).toContain('ADDED'); expect(text).toContain('alice@test.com'); expect(text).toContain('bob@test.com'); expect(text).toContain('ID:'); expect(text).toContain('grp-1'); }); it('shows group detail with no members', async () => { const deps = makeDeps({ id: 'grp-2', name: 'empty-group', description: '', members: [], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'group', 'grp-2']); const text = deps.output.join('\n'); expect(text).toContain('=== Group: empty-group ==='); // No Members section when empty expect(text).not.toContain('EMAIL'); }); it('shows RBAC detail with subjects and bindings', async () => { const deps = makeDeps({ id: 'rbac-1', name: 'developers', subjects: [ { kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'dev-team' }, ], roleBindings: [ { role: 'edit', resource: 'servers' }, { role: 'view', resource: 'instances' }, { role: 'view', resource: 'projects' }, ], createdAt: '2025-01-01', updatedAt: '2025-01-15', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1'); const text = deps.output.join('\n'); expect(text).toContain('=== RBAC: developers ==='); expect(text).toContain('Name:'); expect(text).toContain('developers'); // Subjects section expect(text).toContain('Subjects:'); expect(text).toContain('KIND'); expect(text).toContain('NAME'); expect(text).toContain('User'); expect(text).toContain('alice@test.com'); expect(text).toContain('Group'); expect(text).toContain('dev-team'); // Role Bindings section expect(text).toContain('Resource Bindings:'); expect(text).toContain('ROLE'); expect(text).toContain('RESOURCE'); expect(text).toContain('edit'); expect(text).toContain('servers'); expect(text).toContain('view'); expect(text).toContain('instances'); expect(text).toContain('projects'); expect(text).toContain('ID:'); expect(text).toContain('rbac-1'); }); it('shows RBAC detail with wildcard resource', async () => { const deps = makeDeps({ id: 'rbac-2', name: 'admins', subjects: [{ kind: 'User', name: 'admin@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']); const text = deps.output.join('\n'); expect(text).toContain('=== RBAC: admins ==='); expect(text).toContain('edit'); expect(text).toContain('*'); }); it('shows RBAC detail with empty subjects and bindings', async () => { const deps = makeDeps({ id: 'rbac-3', name: 'empty-rbac', subjects: [], roleBindings: [], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']); const text = deps.output.join('\n'); expect(text).toContain('=== RBAC: empty-rbac ==='); // No Subjects or Role Bindings sections when empty expect(text).not.toContain('KIND'); expect(text).not.toContain('ROLE'); expect(text).not.toContain('RESOURCE'); }); it('shows RBAC detail with mixed resource and operation bindings', async () => { const deps = makeDeps({ id: 'rbac-1', name: 'admin-access', subjects: [{ kind: 'Group', name: 'admin' }], roleBindings: [ { role: 'edit', resource: '*' }, { role: 'run', resource: 'projects' }, { role: 'run', action: 'logs' }, { role: 'run', action: 'backup' }, ], createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); const text = deps.output.join('\n'); expect(text).toContain('Resource Bindings:'); expect(text).toContain('edit'); expect(text).toContain('*'); expect(text).toContain('run'); expect(text).toContain('projects'); expect(text).toContain('Operations:'); expect(text).toContain('ACTION'); expect(text).toContain('logs'); expect(text).toContain('backup'); }); it('shows RBAC detail with name-scoped resource binding', async () => { const deps = makeDeps({ id: 'rbac-1', name: 'ha-viewer', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [ { role: 'view', resource: 'servers', name: 'my-ha' }, { role: 'edit', resource: 'secrets' }, ], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); const text = deps.output.join('\n'); expect(text).toContain('Resource Bindings:'); expect(text).toContain('NAME'); expect(text).toContain('my-ha'); expect(text).toContain('view'); expect(text).toContain('servers'); }); it('shows user with direct RBAC permissions', async () => { const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null, }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // users list (resolveNameOrId) .mockResolvedValueOnce([ // RBAC defs { name: 'dev-access', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [ { role: 'edit', resource: 'servers' }, { role: 'run', action: 'logs' }, ], }, ] as never) .mockResolvedValueOnce([] as never); // groups const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); const text = deps.output.join('\n'); expect(text).toContain('=== User: alice@test.com ==='); expect(text).toContain('Access:'); expect(text).toContain('Direct (dev-access)'); expect(text).toContain('Resources:'); expect(text).toContain('edit'); expect(text).toContain('servers'); expect(text).toContain('Operations:'); expect(text).toContain('logs'); }); it('shows user with inherited group permissions', async () => { const deps = makeDeps({ id: 'usr-1', email: 'bob@test.com', name: 'Bob', provider: null, }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // users list .mockResolvedValueOnce([ // RBAC defs { name: 'team-perms', subjects: [{ kind: 'Group', name: 'dev-team' }], roleBindings: [ { role: 'view', resource: '*' }, { role: 'run', action: 'backup' }, ], }, ] as never) .mockResolvedValueOnce([ // groups { name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] }, ] as never); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); const text = deps.output.join('\n'); expect(text).toContain('Groups:'); expect(text).toContain('dev-team'); expect(text).toContain('Access:'); expect(text).toContain('Inherited (dev-team)'); expect(text).toContain('view'); expect(text).toContain('*'); expect(text).toContain('backup'); }); it('shows user with no permissions', async () => { const deps = makeDeps({ id: 'usr-1', email: 'nobody@test.com', name: null, provider: null, }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([] as never); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); const text = deps.output.join('\n'); expect(text).toContain('Access: (none)'); }); it('shows group with RBAC permissions', async () => { const deps = makeDeps({ id: 'grp-1', name: 'admin', description: 'Admin group', members: [{ user: { email: 'alice@test.com' } }], }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) // groups list (resolveNameOrId) .mockResolvedValueOnce([ // RBAC defs { name: 'admin-access', subjects: [{ kind: 'Group', name: 'admin' }], roleBindings: [ { role: 'edit', resource: '*' }, { role: 'run', action: 'backup' }, { role: 'run', action: 'restore' }, ], }, ] as never); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); const text = deps.output.join('\n'); expect(text).toContain('=== Group: admin ==='); expect(text).toContain('Access:'); expect(text).toContain('Granted (admin-access)'); expect(text).toContain('edit'); expect(text).toContain('*'); expect(text).toContain('backup'); expect(text).toContain('restore'); }); it('shows group with name-scoped permissions', async () => { const deps = makeDeps({ id: 'grp-1', name: 'ha-team', description: 'HA team', members: [], }); vi.mocked(deps.client.get) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ // RBAC defs { name: 'ha-access', subjects: [{ kind: 'Group', name: 'ha-team' }], roleBindings: [ { role: 'edit', resource: 'servers', name: 'my-ha' }, { role: 'view', resource: 'secrets' }, ], }, ] as never); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); const text = deps.output.join('\n'); expect(text).toContain('Access:'); expect(text).toContain('Granted (ha-access)'); expect(text).toContain('my-ha'); expect(text).toContain('NAME'); }); it('outputs user detail as JSON', async () => { const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']); const parsed = JSON.parse(deps.output[0] ?? ''); expect(parsed.email).toBe('alice@test.com'); expect(parsed.role).toBe('ADMIN'); }); it('outputs group detail as YAML', async () => { const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']); expect(deps.output[0]).toContain('name: dev-team'); }); it('outputs rbac detail as JSON', async () => { const deps = makeDeps({ id: 'rbac-1', name: 'devs', subjects: [{ kind: 'User', name: 'a@b.com' }], roleBindings: [{ role: 'edit', resource: 'servers' }], }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']); const parsed = JSON.parse(deps.output[0] ?? ''); expect(parsed.subjects).toHaveLength(1); expect(parsed.roleBindings[0].role).toBe('edit'); }); });