import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createCreateCommand } from '../../src/commands/create.js'; import { type ApiClient, ApiError } 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 () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('create command', () => { let client: ReturnType; let output: string[]; const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); beforeEach(() => { client = mockClient(); output = []; }); describe('create server', () => { it('creates a server with minimal flags', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['server', 'my-server'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'my-server', transport: 'STDIO', replicas: 1, })); expect(output.join('\n')).toContain("server 'test' created"); }); it('creates a server with all flags', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'server', 'ha-mcp', '-d', 'Home Assistant MCP', '--docker-image', 'ghcr.io/ha-mcp:latest', '--transport', 'STREAMABLE_HTTP', '--external-url', 'http://localhost:8086/mcp', '--container-port', '3000', '--replicas', '2', '--command', 'python', '--command', '-c', '--command', 'print("hello")', '--env', 'API_KEY=secretRef:creds:API_KEY', '--env', 'BASE_URL=http://localhost', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', { name: 'ha-mcp', description: 'Home Assistant MCP', dockerImage: 'ghcr.io/ha-mcp:latest', transport: 'STREAMABLE_HTTP', externalUrl: 'http://localhost:8086/mcp', containerPort: 3000, replicas: 2, command: ['python', '-c', 'print("hello")'], env: [ { name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } }, { name: 'BASE_URL', value: 'http://localhost' }, ], }); }); it('defaults transport to STDIO', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['server', 'test'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ transport: 'STDIO', })); }); it('strips null values from template when using --from-template', async () => { vi.mocked(client.get).mockResolvedValueOnce([{ id: 'tpl-1', name: 'grafana', version: '1.0.0', description: 'Grafana MCP', packageName: '@leval/mcp-grafana', dockerImage: null, transport: 'STDIO', repositoryUrl: 'https://github.com/test', externalUrl: null, command: null, containerPort: null, replicas: 1, env: [{ name: 'TOKEN', required: true, description: 'A token' }], healthCheck: { tool: 'test', arguments: {} }, createdAt: '2025-01-01', updatedAt: '2025-01-01', }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'server', 'my-grafana', '--from-template=grafana', '--env', 'TOKEN=secretRef:creds:TOKEN', ], { from: 'user' }); const call = vi.mocked(client.post).mock.calls[0]![1] as Record; // null fields from template should NOT be in the body expect(call).not.toHaveProperty('dockerImage'); expect(call).not.toHaveProperty('externalUrl'); expect(call).not.toHaveProperty('command'); expect(call).not.toHaveProperty('containerPort'); // non-null fields should be present expect(call.packageName).toBe('@leval/mcp-grafana'); expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} }); expect(call.templateName).toBe('grafana'); }); it('throws on 409 without --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}')); const cmd = createCreateCommand({ client, log }); await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409'); }); it('updates existing server on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ transport: 'STDIO', })); expect(output.join('\n')).toContain("server 'my-server' updated"); }); }); describe('create secret', () => { it('creates a secret with --data flags', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'secret', 'ha-creds', '--data', 'TOKEN=abc123', '--data', 'URL=https://ha.local', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', { name: 'ha-creds', data: { TOKEN: 'abc123', URL: 'https://ha.local' }, }); expect(output.join('\n')).toContain("secret 'test' created"); }); it('creates a secret with empty data', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', { name: 'empty-secret', data: {}, }); }); it('throws on 409 without --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}')); const cmd = createCreateCommand({ client, log }); await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409'); }); it('updates existing secret on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } }); expect(output.join('\n')).toContain("secret 'my-creds' updated"); }); }); describe('create project', () => { it('creates a project', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { name: 'my-project', description: 'A test project', proxyMode: 'direct', }); expect(output.join('\n')).toContain("project 'test' created"); }); it('creates a project with no description', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['project', 'minimal'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { name: 'minimal', description: '', proxyMode: 'direct', }); }); it('updates existing project on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' }); expect(output.join('\n')).toContain("project 'my-proj' updated"); }); }); describe('create user', () => { it('creates a user with password and name', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'alice@test.com' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'user', 'alice@test.com', '--password', 'secret123', '--name', 'Alice', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/users', { email: 'alice@test.com', password: 'secret123', name: 'Alice', }); expect(output.join('\n')).toContain("user 'alice@test.com' created"); }); it('does not send role field (RBAC is the auth mechanism)', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'admin@test.com' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'user', 'admin@test.com', '--password', 'pass123', ], { from: 'user' }); const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record; expect(callBody).not.toHaveProperty('role'); }); it('requires --password', async () => { const cmd = createCreateCommand({ client, log }); await expect(cmd.parseAsync(['user', 'alice@test.com'], { from: 'user' })).rejects.toThrow('--password is required'); }); it('throws on 409 without --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}')); const cmd = createCreateCommand({ client, log }); await expect( cmd.parseAsync(['user', 'alice@test.com', '--password', 'pass'], { from: 'user' }), ).rejects.toThrow('API error 409'); }); it('updates existing user on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'usr-1', email: 'alice@test.com' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'user', 'alice@test.com', '--password', 'newpass', '--name', 'Alice New', '--force', ], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', { password: 'newpass', name: 'Alice New', }); expect(output.join('\n')).toContain("user 'alice@test.com' updated"); }); }); describe('create group', () => { it('creates a group with members', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'dev-team' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'group', 'dev-team', '--description', 'Development team', '--member', 'alice@test.com', '--member', 'bob@test.com', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/groups', { name: 'dev-team', description: 'Development team', members: ['alice@test.com', 'bob@test.com'], }); expect(output.join('\n')).toContain("group 'dev-team' created"); }); it('creates a group with no members', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'empty-group' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['group', 'empty-group'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/groups', { name: 'empty-group', members: [], }); }); it('throws on 409 without --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}')); const cmd = createCreateCommand({ client, log }); await expect( cmd.parseAsync(['group', 'dev-team'], { from: 'user' }), ).rejects.toThrow('API error 409'); }); it('updates existing group on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'grp-1', name: 'dev-team' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'group', 'dev-team', '--member', 'new@test.com', '--force', ], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', { members: ['new@test.com'], }); expect(output.join('\n')).toContain("group 'dev-team' updated"); }); }); describe('create rbac', () => { it('creates an RBAC definition with subjects and bindings', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'developers' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'rbac', 'developers', '--subject', 'User:alice@test.com', '--subject', 'Group:dev-team', '--binding', 'edit:servers', '--binding', 'view:instances', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { 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("rbac 'developers' created"); }); it('creates an RBAC definition with wildcard resource', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'admins' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'rbac', 'admins', '--subject', 'User:admin@test.com', '--binding', 'edit:*', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { name: 'admins', subjects: [{ kind: 'User', name: 'admin@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }], }); }); it('creates an RBAC definition with empty subjects and bindings', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'empty' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['rbac', 'empty'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { name: 'empty', subjects: [], roleBindings: [], }); }); it('throws on invalid subject format', async () => { const cmd = createCreateCommand({ client, log }); await expect( cmd.parseAsync(['rbac', 'bad', '--subject', 'no-colon'], { from: 'user' }), ).rejects.toThrow('Invalid subject format'); }); it('throws on invalid binding format', async () => { const cmd = createCreateCommand({ client, log }); await expect( cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }), ).rejects.toThrow('Invalid binding format'); }); it('throws on 409 without --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}')); const cmd = createCreateCommand({ client, log }); await expect( cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }), ).rejects.toThrow('API error 409'); }); it('updates existing RBAC on 409 with --force', async () => { vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}')); vi.mocked(client.get).mockResolvedValueOnce([{ id: 'rbac-1', name: 'developers' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'rbac', 'developers', '--subject', 'User:new@test.com', '--binding', 'edit:*', '--force', ], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', { subjects: [{ kind: 'User', name: 'new@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }], }); expect(output.join('\n')).toContain("rbac 'developers' updated"); }); it('creates an RBAC definition with operation bindings', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'rbac', 'ops', '--subject', 'Group:ops-team', '--binding', 'edit:servers', '--operation', 'logs', '--operation', 'backup', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { name: 'ops', subjects: [{ kind: 'Group', name: 'ops-team' }], roleBindings: [ { role: 'edit', resource: 'servers' }, { role: 'run', action: 'logs' }, { role: 'run', action: 'backup' }, ], }); expect(output.join('\n')).toContain("rbac 'ops' created"); }); it('creates an RBAC definition with name-scoped binding', async () => { vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ha-viewer' }); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ 'rbac', 'ha-viewer', '--subject', 'User:alice@test.com', '--binding', 'view:servers:my-ha', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { name: 'ha-viewer', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [ { role: 'view', resource: 'servers', name: 'my-ha' }, ], }); }); }); });