Files
mcpctl/src/mcpd/tests/secret-routes.test.ts
Michal 6d9a9f572c 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

171 lines
5.3 KiB
TypeScript

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);
});
});
});