2026-02-21 04:55:45 +00:00
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
|
import { createDescribeCommand } from '../../src/commands/describe.js';
|
|
|
|
|
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
|
2026-02-22 14:33:25 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-21 04:55:45 +00:00
|
|
|
|
|
|
|
|
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
|
|
|
|
|
const output: string[] = [];
|
|
|
|
|
return {
|
|
|
|
|
output,
|
2026-02-22 14:33:25 +00:00
|
|
|
client: mockClient(),
|
2026-02-21 04:55:45 +00:00
|
|
|
fetchResource: vi.fn(async () => item),
|
|
|
|
|
log: (...args: string[]) => output.push(args.join(' ')),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('describe command', () => {
|
2026-02-22 14:33:25 +00:00
|
|
|
it('shows detailed server info with sections', async () => {
|
2026-02-21 04:55:45 +00:00
|
|
|
const deps = makeDeps({
|
|
|
|
|
id: 'srv-1',
|
|
|
|
|
name: 'slack',
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
packageName: '@slack/mcp',
|
|
|
|
|
dockerImage: null,
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
env: [],
|
2026-02-22 14:33:25 +00:00
|
|
|
createdAt: '2025-01-01',
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|
|
|
|
|
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');
|
2026-02-22 14:33:25 +00:00
|
|
|
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:');
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves resource aliases', async () => {
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
const deps = makeDeps({ id: 's1' });
|
2026-02-21 04:55:45 +00:00
|
|
|
const cmd = createDescribeCommand(deps);
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
await cmd.parseAsync(['node', 'test', 'sec', 's1']);
|
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-22 14:33:25 +00:00
|
|
|
it('shows project detail', async () => {
|
2026-02-21 04:55:45 +00:00
|
|
|
const deps = makeDeps({
|
2026-02-22 14:33:25 +00:00
|
|
|
id: 'proj-1',
|
|
|
|
|
name: 'my-project',
|
|
|
|
|
description: 'A test project',
|
|
|
|
|
ownerId: 'user-1',
|
|
|
|
|
createdAt: '2025-01-01',
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|
|
|
|
|
const cmd = createDescribeCommand(deps);
|
2026-02-22 14:33:25 +00:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
|
|
|
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('***');
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-22 14:33:25 +00:00
|
|
|
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']);
|
2026-02-21 04:55:45 +00:00
|
|
|
|
|
|
|
|
const text = deps.output.join('\n');
|
2026-02-22 14:33:25 +00:00
|
|
|
expect(text).toContain('=== Instance: inst-1 ===');
|
|
|
|
|
expect(text).toContain('RUNNING');
|
|
|
|
|
expect(text).toContain('abc123');
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|
2026-02-23 00:26:28 +00:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-02-21 04:55:45 +00:00
|
|
|
});
|