2026-02-21 05:14:43 +00:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { createApplyCommand } from '../../src/commands/apply.js';
|
|
|
|
|
import type { ApiClient } from '../../src/api-client.js';
|
|
|
|
|
|
|
|
|
|
function mockClient(): ApiClient {
|
|
|
|
|
return {
|
|
|
|
|
get: vi.fn(async () => []),
|
|
|
|
|
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
|
|
|
|
|
put: vi.fn(async () => ({ id: 'existing-id', name: 'test' })),
|
|
|
|
|
delete: vi.fn(async () => {}),
|
|
|
|
|
} as unknown as ApiClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('apply command', () => {
|
|
|
|
|
let client: ReturnType<typeof mockClient>;
|
|
|
|
|
let output: string[];
|
|
|
|
|
let tmpDir: string;
|
|
|
|
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
client = mockClient();
|
|
|
|
|
output = [];
|
|
|
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies servers from YAML file', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
servers:
|
|
|
|
|
- name: slack
|
|
|
|
|
description: Slack MCP server
|
|
|
|
|
transport: STDIO
|
|
|
|
|
packageName: "@anthropic/slack-mcp"
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'slack' }));
|
|
|
|
|
expect(output.join('\n')).toContain('Created server: slack');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies servers from JSON file', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.json');
|
|
|
|
|
writeFileSync(configPath, JSON.stringify({
|
|
|
|
|
servers: [{ name: 'github', transport: 'STDIO' }],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'github' }));
|
|
|
|
|
expect(output.join('\n')).toContain('Created server: github');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing servers', async () => {
|
|
|
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'slack' }]);
|
|
|
|
|
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
servers:
|
|
|
|
|
- name: slack
|
|
|
|
|
description: Updated description
|
|
|
|
|
transport: STDIO
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ name: 'slack' }));
|
|
|
|
|
expect(output.join('\n')).toContain('Updated server: slack');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('supports dry-run mode', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
servers:
|
|
|
|
|
- name: test
|
|
|
|
|
transport: STDIO
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).not.toHaveBeenCalled();
|
|
|
|
|
expect(output.join('\n')).toContain('Dry run');
|
|
|
|
|
expect(output.join('\n')).toContain('1 server(s)');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
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('applies secrets', async () => {
|
2026-02-21 05:14:43 +00:00
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
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
|
|
|
secrets:
|
|
|
|
|
- name: ha-creds
|
|
|
|
|
data:
|
|
|
|
|
TOKEN: abc123
|
|
|
|
|
URL: https://ha.local
|
2026-02-21 05:14:43 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
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
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
|
|
|
|
|
name: 'ha-creds',
|
|
|
|
|
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
2026-02-21 05:14:43 +00:00
|
|
|
}));
|
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
|
|
|
expect(output.join('\n')).toContain('Created secret: ha-creds');
|
2026-02-21 05:14:43 +00:00
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
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('updates existing secrets', async () => {
|
|
|
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
|
|
|
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
|
|
|
|
|
return [];
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 05:14:43 +00:00
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
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
|
|
|
secrets:
|
|
|
|
|
- name: ha-creds
|
|
|
|
|
data:
|
|
|
|
|
TOKEN: new-token
|
2026-02-21 05:14:43 +00:00
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
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
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
|
|
|
|
|
expect(output.join('\n')).toContain('Updated secret: ha-creds');
|
2026-02-21 05:14:43 +00:00
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies projects', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
projects:
|
|
|
|
|
- name: my-project
|
|
|
|
|
description: A test project
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ name: 'my-project' }));
|
|
|
|
|
expect(output.join('\n')).toContain('Created project: my-project');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
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('applies users (no role field)', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
users:
|
|
|
|
|
- email: alice@test.com
|
|
|
|
|
password: password123
|
|
|
|
|
name: Alice
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
|
|
|
|
expect(callBody).toEqual(expect.objectContaining({
|
|
|
|
|
email: 'alice@test.com',
|
|
|
|
|
password: 'password123',
|
|
|
|
|
name: 'Alice',
|
|
|
|
|
}));
|
|
|
|
|
expect(callBody).not.toHaveProperty('role');
|
|
|
|
|
expect(output.join('\n')).toContain('Created user: alice@test.com');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing users matched by email', async () => {
|
|
|
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
|
|
|
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
|
|
|
|
|
return [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
users:
|
|
|
|
|
- email: alice@test.com
|
|
|
|
|
password: newpassword
|
|
|
|
|
name: Alice Updated
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
|
|
|
|
|
email: 'alice@test.com',
|
|
|
|
|
name: 'Alice Updated',
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Updated user: alice@test.com');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies groups', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
groups:
|
|
|
|
|
- name: dev-team
|
|
|
|
|
description: Development team
|
|
|
|
|
members:
|
|
|
|
|
- alice@test.com
|
|
|
|
|
- bob@test.com
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
|
|
|
|
|
name: 'dev-team',
|
|
|
|
|
description: 'Development team',
|
|
|
|
|
members: ['alice@test.com', 'bob@test.com'],
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Created group: dev-team');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing groups', async () => {
|
|
|
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
|
|
|
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
|
|
|
|
|
return [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
groups:
|
|
|
|
|
- name: dev-team
|
|
|
|
|
description: Updated devs
|
|
|
|
|
members:
|
|
|
|
|
- new@test.com
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
|
|
|
|
|
name: 'dev-team',
|
|
|
|
|
description: 'Updated devs',
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Updated group: dev-team');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies rbacBindings', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
rbac:
|
|
|
|
|
- name: developers
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: User
|
|
|
|
|
name: alice@test.com
|
|
|
|
|
- kind: Group
|
|
|
|
|
name: dev-team
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: edit
|
|
|
|
|
resource: servers
|
|
|
|
|
- role: view
|
|
|
|
|
resource: instances
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
|
|
|
name: 'developers',
|
|
|
|
|
subjects: [
|
|
|
|
|
{ kind: 'User', name: 'alice@test.com' },
|
|
|
|
|
{ kind: 'Group', name: 'dev-team' },
|
|
|
|
|
],
|
|
|
|
|
roleBindings: [
|
|
|
|
|
{ role: 'edit', resource: 'servers' },
|
|
|
|
|
{ role: 'view', resource: 'instances' },
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Created rbacBinding: developers');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing rbacBindings', async () => {
|
|
|
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
|
|
|
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
|
|
|
|
|
return [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
rbacBindings:
|
|
|
|
|
- name: developers
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: User
|
|
|
|
|
name: new@test.com
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: edit
|
|
|
|
|
resource: "*"
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
|
|
|
|
|
name: 'developers',
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 17:50:01 +00:00
|
|
|
it('applies projects with servers', async () => {
|
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
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
projects:
|
|
|
|
|
- name: smart-home
|
|
|
|
|
description: Home automation
|
|
|
|
|
proxyMode: filtered
|
|
|
|
|
llmProvider: gemini-cli
|
|
|
|
|
llmModel: gemini-2.0-flash
|
|
|
|
|
servers:
|
|
|
|
|
- my-grafana
|
|
|
|
|
- my-ha
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
|
|
|
|
name: 'smart-home',
|
|
|
|
|
proxyMode: 'filtered',
|
|
|
|
|
llmProvider: 'gemini-cli',
|
|
|
|
|
llmModel: 'gemini-2.0-flash',
|
|
|
|
|
servers: ['my-grafana', 'my-ha'],
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Created project: smart-home');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('dry-run shows all new resource types', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
secrets:
|
|
|
|
|
- name: creds
|
|
|
|
|
data:
|
|
|
|
|
TOKEN: abc
|
|
|
|
|
users:
|
|
|
|
|
- email: alice@test.com
|
|
|
|
|
password: password123
|
|
|
|
|
groups:
|
|
|
|
|
- name: dev-team
|
|
|
|
|
members: []
|
|
|
|
|
projects:
|
|
|
|
|
- name: my-proj
|
|
|
|
|
description: A project
|
|
|
|
|
rbacBindings:
|
|
|
|
|
- name: admins
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: User
|
|
|
|
|
name: admin@test.com
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: edit
|
|
|
|
|
resource: "*"
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).not.toHaveBeenCalled();
|
|
|
|
|
const text = output.join('\n');
|
|
|
|
|
expect(text).toContain('Dry run');
|
|
|
|
|
expect(text).toContain('1 secret(s)');
|
|
|
|
|
expect(text).toContain('1 user(s)');
|
|
|
|
|
expect(text).toContain('1 group(s)');
|
|
|
|
|
expect(text).toContain('1 project(s)');
|
|
|
|
|
expect(text).toContain('1 rbacBinding(s)');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies resources in correct order', async () => {
|
|
|
|
|
const callOrder: string[] = [];
|
|
|
|
|
vi.mocked(client.post).mockImplementation(async (url: string) => {
|
|
|
|
|
callOrder.push(url);
|
|
|
|
|
return { id: 'new-id', name: 'test' };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
rbacBindings:
|
|
|
|
|
- name: admins
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: User
|
|
|
|
|
name: admin@test.com
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: edit
|
|
|
|
|
resource: "*"
|
|
|
|
|
users:
|
|
|
|
|
- email: admin@test.com
|
|
|
|
|
password: password123
|
|
|
|
|
secrets:
|
|
|
|
|
- name: creds
|
|
|
|
|
data:
|
|
|
|
|
KEY: val
|
|
|
|
|
groups:
|
|
|
|
|
- name: dev-team
|
|
|
|
|
servers:
|
|
|
|
|
- name: my-server
|
|
|
|
|
transport: STDIO
|
|
|
|
|
projects:
|
|
|
|
|
- name: my-proj
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
|
|
|
|
|
expect(callOrder[0]).toBe('/api/v1/secrets');
|
|
|
|
|
expect(callOrder[1]).toBe('/api/v1/servers');
|
|
|
|
|
expect(callOrder[2]).toBe('/api/v1/users');
|
|
|
|
|
expect(callOrder[3]).toBe('/api/v1/groups');
|
|
|
|
|
expect(callOrder[4]).toBe('/api/v1/projects');
|
|
|
|
|
expect(callOrder[5]).toBe('/api/v1/rbac');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies rbac with operation bindings', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
rbac:
|
|
|
|
|
- name: ops-team
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: Group
|
|
|
|
|
name: ops
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: edit
|
|
|
|
|
resource: servers
|
|
|
|
|
- role: run
|
|
|
|
|
action: backup
|
|
|
|
|
- role: run
|
|
|
|
|
action: logs
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
|
|
|
name: 'ops-team',
|
|
|
|
|
roleBindings: [
|
|
|
|
|
{ role: 'edit', resource: 'servers' },
|
|
|
|
|
{ role: 'run', action: 'backup' },
|
|
|
|
|
{ role: 'run', action: 'logs' },
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies rbac with name-scoped resource binding', async () => {
|
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
|
|
|
writeFileSync(configPath, `
|
|
|
|
|
rbac:
|
|
|
|
|
- name: ha-viewer
|
|
|
|
|
subjects:
|
|
|
|
|
- kind: User
|
|
|
|
|
name: alice@test.com
|
|
|
|
|
roleBindings:
|
|
|
|
|
- role: view
|
|
|
|
|
resource: servers
|
|
|
|
|
name: my-ha
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
|
|
|
name: 'ha-viewer',
|
|
|
|
|
roleBindings: [
|
|
|
|
|
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
2026-02-21 05:14:43 +00:00
|
|
|
});
|