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>
This commit is contained in:
Michal
2026-02-22 18:40:58 +00:00
parent ede9e10990
commit 6d9a9f572c
77 changed files with 1014 additions and 1931 deletions

View File

@@ -4,7 +4,7 @@ import { BackupService } from '../src/services/backup/backup-service.js';
import { RestoreService } from '../src/services/backup/restore-service.js';
import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js';
import { registerBackupRoutes } from '../src/routes/backup.js';
import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
// Mock data
@@ -12,19 +12,19 @@ const mockServers = [
{
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
{
id: 's2', name: 'slack', description: 'Slack MCP', packageName: null,
dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
const mockProfiles = [
const mockSecrets = [
{
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'],
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
id: 'sec1', name: 'github-secrets',
data: { GITHUB_TOKEN: 'ghp_secret123' },
version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
findAll: vi.fn(async () => [...mockServers]),
findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
function mockProfileRepo(): IMcpProfileRepository {
function mockSecretRepo(): ISecretRepository {
return {
findAll: vi.fn(async () => [...mockProfiles]),
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })),
findAll: vi.fn(async () => [...mockSecrets]),
findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])),
update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
@@ -66,8 +66,6 @@ function mockProjectRepo(): IProjectRepository {
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => ['p1']),
};
}
@@ -112,7 +110,7 @@ describe('BackupService', () => {
let backupService: BackupService;
beforeEach(() => {
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo());
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
});
it('creates backup with all resources', async () => {
@@ -121,43 +119,43 @@ describe('BackupService', () => {
expect(bundle.version).toBe('1');
expect(bundle.encrypted).toBe(false);
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(1);
expect(bundle.secrets).toHaveLength(1);
expect(bundle.projects).toHaveLength(1);
expect(bundle.servers[0]!.name).toBe('github');
expect(bundle.profiles[0]!.serverName).toBe('github');
expect(bundle.secrets[0]!.name).toBe('github-secrets');
expect(bundle.projects[0]!.name).toBe('my-project');
});
it('filters resources', async () => {
const bundle = await backupService.createBackup({ resources: ['servers'] });
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
it('encrypts sensitive env values when password provided', async () => {
it('encrypts sensitive secret values when password provided', async () => {
const bundle = await backupService.createBackup({ password: 'test-pass' });
expect(bundle.encrypted).toBe(true);
expect(bundle.encryptedSecrets).toBeDefined();
// The GITHUB_TOKEN should be replaced with placeholder
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
const data = bundle.secrets[0]!.data;
expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
});
it('handles empty repositories', async () => {
const emptyServerRepo = mockServerRepo();
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProfileRepo = mockProfileRepo();
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptySecretRepo = mockSecretRepo();
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProjectRepo = mockProjectRepo();
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo);
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
const bundle = await service.createBackup();
expect(bundle.servers).toHaveLength(0);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
});
@@ -165,18 +163,18 @@ describe('BackupService', () => {
describe('RestoreService', () => {
let restoreService: RestoreService;
let serverRepo: IMcpServerRepository;
let profileRepo: IMcpProfileRepository;
let secretRepo: ISecretRepository;
let projectRepo: IProjectRepository;
beforeEach(() => {
serverRepo = mockServerRepo();
profileRepo = mockProfileRepo();
secretRepo = mockSecretRepo();
projectRepo = mockProjectRepo();
// Default: nothing exists yet
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
});
const validBundle = {
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
mcpctlVersion: '0.1.0',
createdAt: new Date().toISOString(),
encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }],
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
projects: [{ name: 'test-proj', description: 'Test' }],
};
it('validates valid bundle', () => {
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
const result = await restoreService.restore(validBundle);
expect(result.serversCreated).toBe(1);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
expect(result.projectsCreated).toBe(1);
expect(result.errors).toHaveLength(0);
expect(serverRepo.create).toHaveBeenCalled();
expect(profileRepo.create).toHaveBeenCalled();
expect(secretRepo.create).toHaveBeenCalled();
expect(projectRepo.create).toHaveBeenCalled();
});
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
});
it('restores encrypted bundle with correct password', async () => {
const secrets = { 'profile:default:API_KEY': 'secret-val' };
const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' };
const encBundle = {
...validBundle,
encrypted: true,
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
};
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
expect(result.errors).toHaveLength(0);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
});
it('fails with wrong decryption password', async () => {
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
beforeEach(() => {
const sRepo = mockServerRepo();
const pRepo = mockProfileRepo();
const secRepo = mockSecretRepo();
const prRepo = mockProjectRepo();
backupService = new BackupService(sRepo, pRepo, prRepo);
backupService = new BackupService(sRepo, prRepo, secRepo);
const rSRepo = mockServerRepo();
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPRepo = mockProfileRepo();
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rSecRepo = mockSecretRepo();
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPrRepo = mockProjectRepo();
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
});
async function buildApp() {
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
const body = res.json();
expect(body.version).toBe('1');
expect(body.servers).toBeDefined();
expect(body.profiles).toBeDefined();
expect(body.secrets).toBeDefined();
expect(body.projects).toBeDefined();
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest';
import { resolveServerEnv } from '../src/services/env-resolver.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
import type { McpServer } from '@prisma/client';
function makeServer(env: unknown[]): McpServer {
return {
id: 'srv-1',
name: 'test-server',
description: '',
packageName: null,
dockerImage: 'test:latest',
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpServer;
}
function mockSecretRepo(secrets: Record<string, Record<string, string>>): ISecretRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async (name: string) => {
const data = secrets[name];
if (!data) return null;
return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() };
}),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('resolveServerEnv', () => {
it('resolves inline values', async () => {
const server = makeServer([
{ name: 'FOO', value: 'bar' },
{ name: 'BAZ', value: 'qux' },
]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('resolves secret references', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
]);
const repo = mockSecretRepo({
'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ TOKEN: 'secret-token-123' });
});
it('handles mixed inline and secret refs', async () => {
const server = makeServer([
{ name: 'URL', value: 'https://ha.local' },
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({
creds: { TOKEN: 'my-token' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' });
});
it('caches secret lookups', async () => {
const server = makeServer([
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
]);
const repo = mockSecretRepo({
shared: { KEY_A: 'val-a', KEY_B: 'val-b' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ A: 'val-a', B: 'val-b' });
expect(repo.findByName).toHaveBeenCalledTimes(1);
});
it('throws when secret not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found");
});
it('throws when secret key not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
]);
const repo = mockSecretRepo({
creds: { OTHER_KEY: 'val' },
});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'");
});
it('returns empty map for empty env', async () => {
const server = makeServer([]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({});
});
});

View File

@@ -83,7 +83,7 @@ function makeServer(overrides: Partial<{ id: string; name: string; replicas: num
command: overrides.command ?? null,
containerPort: overrides.containerPort ?? null,
replicas: overrides.replicas ?? 1,
envTemplate: [],
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -1,22 +1,8 @@
import { describe, it, expect } from 'vitest';
import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js';
import type { McpServer } from '@prisma/client';
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] {
return {
id: 'p1',
name: 'default',
serverId: 's1',
permissions: [],
envOverrides: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
return {
id: 's1',
name: 'slack',
@@ -25,7 +11,7 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -34,76 +20,51 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
}
describe('generateMcpConfig', () => {
it('returns empty mcpServers for empty profiles', () => {
it('returns empty mcpServers for empty input', () => {
const result = generateMcpConfig([]);
expect(result).toEqual({ mcpServers: {} });
});
it('generates config for a single profile', () => {
it('generates config for a single server', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server: makeServer() },
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']).toBeDefined();
expect(result.mcpServers['slack--default']?.command).toBe('npx');
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['slack']?.command).toBe('npx');
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
});
it('excludes secret env vars from output', () => {
const server = makeServer({
envTemplate: [
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
] as never,
});
it('includes resolved env when present', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
]);
const config = result.mcpServers['slack--default'];
const config = result.mcpServers['slack'];
expect(config?.env).toBeDefined();
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
});
it('applies env overrides from profile (non-secret only)', () => {
const server = makeServer({
envTemplate: [
{ name: 'API_URL', description: 'URL', isSecret: false },
] as never,
});
const profile = makeProfile({
envOverrides: { API_URL: 'https://staging.example.com' } as never,
});
const result = generateMcpConfig([{ profile, server }]);
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
it('omits env when resolvedEnv is empty', () => {
const result = generateMcpConfig([
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack']?.env).toBeUndefined();
});
it('generates multiple server configs', () => {
const result = generateMcpConfig([
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
{ server: makeServer({ name: 'slack' }), resolvedEnv: {} },
{ server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }), resolvedEnv: {} },
]);
expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['slack--readonly']).toBeDefined();
expect(result.mcpServers['github--default']).toBeDefined();
});
it('omits env when no non-secret vars have values', () => {
const server = makeServer({
envTemplate: [
{ name: 'TOKEN', description: 'Secret', isSecret: true },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['github']).toBeDefined();
});
it('uses server name as fallback when packageName is null', () => {
const server = makeServer({ packageName: null });
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server, resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']);
expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
});
});

View File

@@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpProfileService } from '../src/services/mcp-profile.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
serverId: data.serverId,
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: data.name ?? 'test',
serverId: 'srv-1',
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('McpProfileService', () => {
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: McpProfileService;
beforeEach(() => {
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new McpProfileService(profileRepo, serverRepo);
});
describe('list', () => {
it('returns all profiles', async () => {
await service.list();
expect(profileRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('returns profile when found', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a profile when server exists', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
const result = await service.create({ name: 'readonly', serverId: 'srv-1' });
expect(result.name).toBe('readonly');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when profile name exists for server', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('updates an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
await service.update('1', { permissions: ['read'] });
expect(profileRepo.update).toHaveBeenCalled();
});
it('checks uniqueness when renaming', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never);
await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never);
await service.delete('1');
expect(profileRepo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -44,7 +44,7 @@ function createInMemoryServerRepo(): IMcpServerRepository {
command: data.command ?? null,
containerPort: data.containerPort ?? null,
replicas: data.replicas ?? 1,
envTemplate: data.envTemplate ?? [],
env: data.env ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -347,8 +347,8 @@ describe('MCP server full flow', () => {
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
containerPort: 3000,
envTemplate: [
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
env: [
{ name: 'HOMEASSISTANT_TOKEN', value: 'placeholder' },
],
},
});
@@ -463,9 +463,9 @@ describe('MCP server full flow', () => {
transport: 'STREAMABLE_HTTP',
containerPort: 3000,
command: ['python', '-c', 'print("hello")'],
envTemplate: [
{ name: 'HOMEASSISTANT_URL', description: 'HA URL' },
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
env: [
{ name: 'HOMEASSISTANT_URL', value: 'http://localhost:8123' },
{ name: 'HOMEASSISTANT_TOKEN', valueFrom: { secretRef: { name: 'ha-secrets', key: 'token' } } },
],
},
});

View File

@@ -34,7 +34,7 @@ function mockRepo(): IMcpServerRepository {
command: null,
containerPort: null,
replicas: data.replicas ?? 1,
envTemplate: [],
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -55,7 +55,7 @@ function mockRepo(): IMcpServerRepository {
command: null,
containerPort: null,
replicas: 1,
envTemplate: [],
env: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -15,7 +15,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate ?? [],
env: data.env ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -28,7 +28,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null,
transport: 'STDIO' as const,
repositoryUrl: null,
envTemplate: [],
env: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProjectRepo(): IProjectRepository {
return {
@@ -23,19 +23,6 @@ function mockProjectRepo(): IProjectRepository {
createdAt: new Date(), updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => []),
};
}
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
@@ -52,15 +39,13 @@ function mockServerRepo(): IMcpServerRepository {
describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new ProjectService(projectRepo, profileRepo, serverRepo);
service = new ProjectService(projectRepo, serverRepo);
});
describe('create', () => {
@@ -86,55 +71,6 @@ describe('ProjectService', () => {
});
});
describe('setProfiles', () => {
it('sets profile associations', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never);
const result = await service.setProfiles('p1', { profileIds: ['prof-1'] });
expect(result).toEqual(['prof-1']);
expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']);
});
it('throws NotFoundError for missing profile', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for missing project', async () => {
await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError);
});
});
describe('getMcpConfig', () => {
it('returns empty config for project with no profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
const result = await service.getMcpConfig('p1');
expect(result).toEqual({ mcpServers: {} });
});
it('generates config from profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']);
vi.mocked(profileRepo.findById).mockResolvedValue({
id: 'prof-1', name: 'default', serverId: 's1',
permissions: [], envOverrides: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp',
dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [],
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getMcpConfig('p1');
expect(result.mcpServers['slack--default']).toBeDefined();
});
it('throws NotFoundError for missing project', async () => {
await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes project', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerSecretRoutes } from '../src/routes/secrets.js';
import { SecretService } from '../src/services/secret.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
function mockRepo(): ISecretRepository {
let lastCreated: Record<string, unknown> | null = null;
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() },
]),
findById: vi.fn(async (id: string) => {
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
return null;
}),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => {
const secret = {
id: 'new-id',
name: data.name,
data: data.data ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
update: vi.fn(async (id, data) => {
const secret = {
id,
name: 'ha-creds',
data: data.data,
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function createApp(repo: ISecretRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new SecretService(repo);
registerSecretRoutes(app, service);
return app.ready();
}
describe('Secret Routes', () => {
describe('GET /api/v1/secrets', () => {
it('returns secret list', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]?.name).toBe('ha-creds');
});
});
describe('GET /api/v1/secrets/:id', () => {
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
it('returns secret when found', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' } } as never);
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(200);
});
});
describe('POST /api/v1/secrets', () => {
it('creates a secret and returns 201', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'new-secret', data: { KEY: 'val' } },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('new-secret');
});
it('returns 400 for invalid input', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
});
it('returns 409 when name already exists', async () => {
const repo = mockRepo();
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'existing' },
});
expect(res.statusCode).toBe(409);
});
});
describe('PUT /api/v1/secrets/:id', () => {
it('updates a secret', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/1',
payload: { data: { TOKEN: 'new-val' } },
});
expect(res.statusCode).toBe(200);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/missing',
payload: { data: { X: 'y' } },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/secrets/:id', () => {
it('deletes a secret and returns 204', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(204);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -2,8 +2,6 @@ import { describe, it, expect } from 'vitest';
import {
CreateMcpServerSchema,
UpdateMcpServerSchema,
CreateMcpProfileSchema,
UpdateMcpProfileSchema,
} from '../src/validation/index.js';
describe('CreateMcpServerSchema', () => {
@@ -14,7 +12,7 @@ describe('CreateMcpServerSchema', () => {
transport: 'STDIO',
});
expect(result.name).toBe('my-server');
expect(result.envTemplate).toEqual([]);
expect(result.env).toEqual([]);
});
it('rejects empty name', () => {
@@ -39,15 +37,40 @@ describe('CreateMcpServerSchema', () => {
expect(result.transport).toBe('STDIO');
});
it('validates envTemplate entries', () => {
it('validates env entries with inline value', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
envTemplate: [
{ name: 'API_KEY', description: 'The key', isSecret: true },
env: [
{ name: 'API_URL', value: 'https://example.com' },
],
});
expect(result.envTemplate).toHaveLength(1);
expect(result.envTemplate[0]?.isSecret).toBe(true);
expect(result.env).toHaveLength(1);
expect(result.env[0]?.value).toBe('https://example.com');
});
it('validates env entries with secretRef', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'my-secret', key: 'api-key' } } },
],
});
expect(result.env).toHaveLength(1);
expect(result.env[0]?.valueFrom?.secretRef.name).toBe('my-secret');
});
it('rejects env entry with neither value nor valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO' }],
})).toThrow();
});
it('rejects env entry with both value and valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO', value: 'bar', valueFrom: { secretRef: { name: 'x', key: 'y' } } }],
})).toThrow();
});
it('rejects invalid transport', () => {
@@ -78,47 +101,3 @@ describe('UpdateMcpServerSchema', () => {
});
});
describe('CreateMcpProfileSchema', () => {
it('validates valid input', () => {
const result = CreateMcpProfileSchema.parse({
name: 'readonly',
serverId: 'server-123',
});
expect(result.name).toBe('readonly');
expect(result.permissions).toEqual([]);
expect(result.envOverrides).toEqual({});
});
it('rejects empty name', () => {
expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow();
});
it('accepts permissions array', () => {
const result = CreateMcpProfileSchema.parse({
name: 'admin',
serverId: 'x',
permissions: ['read', 'write', 'delete'],
});
expect(result.permissions).toHaveLength(3);
});
it('accepts envOverrides', () => {
const result = CreateMcpProfileSchema.parse({
name: 'staging',
serverId: 'x',
envOverrides: { API_URL: 'https://staging.example.com' },
});
expect(result.envOverrides['API_URL']).toBe('https://staging.example.com');
});
});
describe('UpdateMcpProfileSchema', () => {
it('allows partial updates', () => {
const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] });
expect(result.permissions).toEqual(['read']);
});
it('allows empty object', () => {
expect(UpdateMcpProfileSchema.parse({})).toBeDefined();
});
});