proxyMode "direct" was a security hole (leaked secrets as plaintext env vars in .mcp.json) and bypassed all mcplocal features (gating, audit, RBAC, content pipeline, namespacing). Removed from schema, API, CLI, and all tests. Old configs with proxyMode are accepted but silently stripped via Zod .transform() for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
734 lines
24 KiB
TypeScript
734 lines
24 KiB
TypeScript
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 project Plugin Config with proxyModel', async () => {
|
|
const deps = makeDeps({
|
|
id: 'proj-1',
|
|
name: 'gated-project',
|
|
description: 'A gated project',
|
|
ownerId: 'user-1',
|
|
proxyModel: 'default',
|
|
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('Plugin Config:');
|
|
expect(text).toContain('Plugin:');
|
|
expect(text).toContain('default');
|
|
expect(text).not.toContain('Gated:');
|
|
});
|
|
|
|
it('shows project Plugin Config defaulting to "default" when proxyModel is empty', async () => {
|
|
const deps = makeDeps({
|
|
id: 'proj-1',
|
|
name: 'old-project',
|
|
description: '',
|
|
ownerId: 'user-1',
|
|
proxyModel: '',
|
|
gated: true,
|
|
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('Plugin Config:');
|
|
expect(text).toContain('default');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|