Files
dashcaddy/dashcaddy-api/__tests__/mcp/mcp-core-dc166.test.js
T
Hermes f4f2c26de3 [grade=B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q] fix(DC-170): MCP truth batch — 6 dead tools fixed, full-catalog drift gate
Audit P0-5 + deploy_app truth (batch 5 of the 2026-10-27 sweep, AUDIT-2026-10-27.md committed here):

ROUTE ADDITIONS:
- routes/containers.js GET / — container list (dockerode listContainers);
  the router had NO list route, so dashcaddy_list_containers 404'd always
- routes/dns.js GET /records — record list via active provider with a
  list-records capability guard (501 + guidance for push-only providers
  like RFC2136); dashcaddy_list_dns called GET /dns, never existed

HANDLER FIXES (src/mcp/mcp-core.js):
- get_service resolves from GET /services by id/name (single-service GET
  never existed) with a known-ids 404
- check_health + diagnose -> /health/service/:id and /health/services
  (both previously hit nonexistent routes)
- container_action remove -> DELETE /containers/:id (was /:id/remove)
- system_health/system_metrics via apiCall error envelope (metrics text
  capped at 64KB)
- search_catalog applies category filter client-side (/catalog/search
  has no category param — constraint was silently dropped)
- generate_caddyfile defaults authService=status when auth:true (auth
  flag alone silently produced UNGATED Caddyfiles)
- deploy_app honest re-contract: registers service + generates
  Caddyfile, does NOT claim image pull/container creation/deployed:true;
  base domain read from /config (hardcoded owner .sami TLD removed);
  caddy-code failure now surfaces as caddyfileError + FAILED message
- list_fleet description gains Premium hint

VERIFIER (audit #40): EXPECTED table 12 -> 34 rows covering EVERY tool's
(method,path); NEW Layer-3 handler-derived test drives the real
createToolHandler for all 28 tools and strictly resolves every captured
call mount-aware (a vacuous first draft of this gate was caught and
fixed pre-grade); extractRoutes regex extended to apiRouter.get inline
decls; Bearer credential-passthrough test added (DC-162 coverage gap)
NEW TESTS: dns-records.routes.test.js (5 route-level cases incl. 501
capability behavior); deploy_app partial-failure + no-.sami tests

HOST RELIABILITY (same sweep, evidenced here): jest maxWorkers 50% —
default 5-worker runs repeatedly OOM-killed this host's gateway
(dmesg 2026-10-29 00:58:40; swap was 4G/4G full). Gateway protected
separately via systemd OOMScoreAdjust=-500 drop-in + /swapfile2 (host
ops, graded B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q).
DC-171 DNS provider-parity ticket (Sami, plug-in-directly) appended to
backlog.

Full suite: 173/173 suites, 3740/3740 tests. Judge B r1
(urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq) blockers
all fixed: dns.js 501 arg order (the file aliases error() message-first
— the sweep's 'reversed-arg' finding was a false positive for its 7
sites; lesson #14), deploy_app partial-failure honesty, handler-derived
verifier.
2026-10-29 01:30:42 -07:00

254 lines
13 KiB
JavaScript

/**
* DC-166: MCP control-plane completion — 11 new tools (28 total).
* Proves: tool catalog integrity (names unique/aligned with handlers),
* each new handler hits the RIGHT method+path+body (captured fetchImpl),
* calendar days->ISO window translation, security summary fan-out,
* DNS delete query construction, and credential passthrough on writes.
*/
'use strict';
const { TOOLS, createToolHandler } = require('../../src/mcp/mcp-core');
function harness(handlerOpts = {}) {
const calls = [];
const fetchImpl = async (url, opts) => {
calls.push({ url, method: opts.method, headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : undefined });
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, echo: calls[calls.length - 1] }) };
};
const handleTool = createToolHandler({
baseUrl: 'http://127.0.0.1:1',
apiKey: 'dk_' + 'test_key',
fetchImpl,
...handlerOpts,
});
return { calls, handleTool };
}
const NEW_TOOLS = [
'dashcaddy_calendar_events', 'dashcaddy_calendar_add_source',
'dashcaddy_security_events', 'dashcaddy_security_summary',
'dashcaddy_license_status', 'dashcaddy_capabilities',
'dashcaddy_dns_add_record', 'dashcaddy_dns_remove_record',
'dashcaddy_deploy_recipe', 'dashcaddy_run_workflow',
'dashcaddy_tailscale_status',
];
describe('DC-166 tool catalog', () => {
test('all 11 new tools declared, total 28, names unique', () => {
const names = TOOLS.map((t) => t.name);
for (const n of NEW_TOOLS) expect(names).toContain(n);
expect(names.length).toBe(28);
expect(new Set(names).size).toBe(28);
});
test('every tool has description + object inputSchema', () => {
for (const t of TOOLS) {
expect(typeof t.description).toBe('string');
expect(t.description.length).toBeGreaterThan(10);
expect(t.inputSchema.type).toBe('object');
}
});
test('required fields declared on write tools', () => {
const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
expect(byName.dashcaddy_dns_add_record.inputSchema.required).toEqual(['domain', 'ip']);
expect(byName.dashcaddy_deploy_recipe.inputSchema.required).toEqual(['recipeId']);
expect(byName.dashcaddy_run_workflow.inputSchema.required).toEqual(['workflowId']);
expect(byName.dashcaddy_calendar_add_source.inputSchema.required).toEqual(['name', 'type', 'url']);
expect(byName.dashcaddy_calendar_add_source.inputSchema.properties.type.enum).toEqual(['caldav', 'ics']);
});
});
describe('DC-166 handler routing (captured fetchImpl)', () => {
test('calendar_events translates days into ISO start/end window', async () => {
const { calls, handleTool } = harness();
const before = Date.now();
await handleTool('dashcaddy_calendar_events', { days: 7 });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('GET');
const u = new URL(calls[0].url);
expect(u.pathname).toBe('/api/v1/calendar/events');
const start = new Date(u.searchParams.get('start'));
const end = new Date(u.searchParams.get('end'));
expect(Math.abs(start.getTime() - before)).toBeLessThan(60000);
// ~7-day window: allow 1s scheduling slack around exactly 7*86400s
expect(end - start).toBeGreaterThanOrEqual(7 * 86400e3 - 1000);
expect(end - start).toBeLessThanOrEqual(7 * 86400e3 + 60000);
});
test('calendar_events defaults to 14 days and clamps to 60', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_calendar_events', {});
const u0 = new URL(calls[0].url);
const span0 = new Date(u0.searchParams.get('end')) - new Date(u0.searchParams.get('start'));
expect(Math.round(span0 / 86400e3)).toBe(14);
await handleTool('dashcaddy_calendar_events', { days: 999 });
const u = new URL(calls[1].url);
const span = new Date(u.searchParams.get('end')) - new Date(u.searchParams.get('start'));
expect(Math.round(span / 86400e3)).toBe(60);
});
test('calendar_add_source posts credentials through, omits absent fields', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_calendar_add_source', { name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' });
await handleTool('dashcaddy_calendar_add_source', { name: 'F', type: 'ics', url: 'https://f.example/a.ics' });
await handleTool('dashcaddy_calendar_add_source', { name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/calendar/sources');
expect(calls[0].body).toEqual({ name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' });
expect(calls[1].body.user).toBeUndefined();
expect(calls[1].body.pass).toBeUndefined();
// token auth variant is forwarded (route accepts user+pass OR token)
expect(calls[2].body).toEqual({ name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' });
});
test('security_events passes only provided filters as query string', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_security_events', { limit: 5, severity: 'critical' });
await handleTool('dashcaddy_security_events', {});
const u1 = new URL(calls[0].url);
expect(u1.pathname).toBe('/api/v1/security/events');
expect(u1.searchParams.get('limit')).toBe('5');
expect(u1.searchParams.get('severity')).toBe('critical');
expect(u1.searchParams.get('source_type')).toBeNull();
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/security/events');
});
test('security_summary fans out stats+perimeter, respects withPerimeter false', async () => {
const { calls, handleTool } = harness();
const r1 = await handleTool('dashcaddy_security_summary', {});
expect(calls.map(c => c.url)).toEqual([
'http://127.0.0.1:1/api/v1/security/events/stats',
'http://127.0.0.1:1/api/v1/security/events/perimeter',
]);
expect(r1.stats).toBeDefined();
expect(r1.perimeter).toBeDefined();
calls.length = 0;
const r2 = await handleTool('dashcaddy_security_summary', { withPerimeter: false });
expect(calls).toHaveLength(1);
expect(r2.success).toBe(true);
});
test('license_status and capabilities read the right endpoints', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_license_status', {});
await handleTool('dashcaddy_capabilities', {});
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/license/status');
expect(calls[0].method).toBe('GET');
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/capabilities');
});
test('dns_add_record posts the provider schema body', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_add_record', { domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/dns/record');
expect(calls[0].body).toEqual({ domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 });
await handleTool('dashcaddy_dns_add_record', { domain: 'y.home.example', ip: '10.0.0.6' });
expect(calls[1].body.ttl).toBeUndefined();
});
test('dns_remove_record deletes with encoded query, type defaults A', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_remove_record', { domain: 'a b.example', type: 'CNAME' });
await handleTool('dashcaddy_dns_remove_record', { domain: 'z.example', ipAddress: '1.2.3.4' });
expect(calls[0].method).toBe('DELETE');
const u0 = new URL(calls[0].url);
expect(u0.pathname).toBe('/api/v1/dns/record');
expect(u0.searchParams.get('domain')).toBe('a b.example');
expect(u0.searchParams.get('type')).toBe('CNAME');
const u1 = new URL(calls[1].url);
expect(u1.searchParams.get('type')).toBe('A');
expect(u1.searchParams.get('ipAddress')).toBe('1.2.3.4');
});
test('deploy_recipe maps overrides to config body', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_deploy_recipe', { recipeId: 'media-stack', overrides: { plex: { port: 32400 } } });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/recipes/deploy');
expect(calls[0].body).toEqual({ recipeId: 'media-stack', config: { plex: { port: 32400 } } });
await handleTool('dashcaddy_deploy_recipe', { recipeId: 'x' });
expect(calls[1].body.config).toBeUndefined();
});
test('run_workflow posts encoded id to /run, tailscale_status reads status', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_run_workflow', { workflowId: 'disk space alert' });
expect(calls[0].method).toBe('POST');
expect(decodeURIComponent(calls[0].url)).toBe('http://127.0.0.1:1/api/v1/workflows/disk space alert/run');
expect(calls[0].url).toContain('disk%20space%20alert');
await handleTool('dashcaddy_tailscale_status', {});
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/tailscale/status');
expect(calls[1].method).toBe('GET');
});
test('credential passthrough preserved on the new write tools (x-api-key)', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_add_record', { domain: 'w.example', ip: '1.1.1.1' });
expect(calls[0].headers['x-api-key']).toBe('dk_' + 'test_key');
});
test('credential passthrough preserved for JWT callers (Bearer), not just x-api-key', async () => {
// Merge-judge r2 polish (urn:ump:5clu4udrbwuwxn74tqwxtukfjnrwkrbiykp6uh66ipipzxr32xsq):
// the DC-162 contract preserves the CALLER'S credential TYPE — exercise the
// Bearer path, which previously had zero coverage.
const { calls, handleTool } = harness({ headers: { Authorization: 'Bearer test_jwt' } });
await handleTool('dashcaddy_dns_add_record', { domain: 'w.example', ip: '1.1.1.1' });
expect(calls[0].headers['Authorization']).toBe('Bearer ' + 'test_jwt');
expect(calls[0].headers['x-api-key']).toBeUndefined();
});
test('unknown tool still rejected after catalog extension', async () => {
const { handleTool } = harness();
const r = await handleTool('dashcaddy_delete_everything', {});
expect(r.error).toBe(true);
expect(r.message).toMatch(/Unknown tool/);
});
test('deploy_app reports partial failure when Caddyfile generation fails (DC-170)', async () => {
// r2 judge blocker (urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq):
// a caddy-code failure was swallowed — registered:true was returned while
// the message claimed the route was generated.
const calls = [];
const fetchImpl = async (url, opts = {}) => {
calls.push(String(url));
if (String(url).includes('/caddycode/generate')) {
return { ok: false, status: 500, text: async () => JSON.stringify({ error: 'caddy templating exploded' }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({
success: true, dashboardHost: 'status.example.net',
apps: [{ id: 'plex', name: 'Plex', category: 'media', image: 'plex/plex', ports: [32400] }],
}) };
};
const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_x', fetchImpl });
const r = await handleTool('dashcaddy_deploy_app', { templateId: 'plex' });
expect(calls.filter((c) => c.includes('/config'))).toHaveLength(1); // base domain from /config, not hardcoded .sami
expect(r.registered).toBe(true);
expect(r.caddyfile).toBeNull();
expect(r.caddyfileError).toMatch(/caddy templating exploded/);
expect(r.message).toMatch(/FAILED/);
expect(r.url).toBe('https://plex.example.net');
});
test('deploy_app derives the base domain from /config (no hardcoded .sami)', async () => {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
calls.push({ url: String(url), body: opts.body ? JSON.parse(opts.body) : undefined });
return { ok: true, status: 200, text: async () => JSON.stringify({
success: true, dashboardHost: 'status.example.net',
apps: [{ id: 'plex', name: 'Plex', category: 'media', image: 'plex/plex', ports: [32400] }],
caddyfile: 'generated-route',
}) };
};
const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_x', fetchImpl });
const r = await handleTool('dashcaddy_deploy_app', { templateId: 'plex', subdomain: 'plex' });
expect(r.registered).toBe(true);
expect(r.url).toBe('https://plex.example.net');
const svcCall = calls.find((c) => c.url.includes('/services'));
expect(svcCall.body.domain).toBe('plex.example.net');
expect(JSON.stringify(svcCall.body)).not.toMatch(/\.sami/);
});
});