Files
sencho/backend/src/__tests__/effective-service-model.test.ts
T
Anso 78475d96ef fix(compose-doctor): resolve effective healthcheck coverage (#1713)
* fix(compose-doctor): resolve effective healthcheck coverage

Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active.

* fix(compose-doctor): fix healthcheck project label and empty compose HC

Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active.

* fix(compose-doctor): treat inherited healthcheck as All Clear note

Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
2026-07-28 14:26:00 -04:00

135 lines
5.9 KiB
TypeScript

/**
* buildEffectiveServiceModel: turns `docker compose config --format json`
* output into the per-service facts (declared image, build presence,
* expected replicas, dependencies, healthcheck) that service-scoped update
* and restore key off of. Fail-closed is the critical property: a render
* failure must leave `renderable: false` with no services, never a
* root-file fallback.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { ComposeService } from '../services/ComposeService';
import { buildEffectiveServiceModel } from '../services/effectiveServiceModel';
const ENV_SECRET = 'env-secret-9d4a-value';
function stubRender(rendered: string | null, stderr = '') {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
} as unknown as ComposeService);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('buildEffectiveServiceModel', () => {
it('extracts declared image, dependsOn, and healthcheck for an image-only service', async () => {
stubRender(JSON.stringify({
services: {
web: {
image: 'nginx:latest',
depends_on: { db: { condition: 'service_healthy', required: true } },
healthcheck: { test: ['CMD', 'true'] },
},
db: { image: 'postgres:16' },
},
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
expect(result.renderable).toBe(true);
if (!result.renderable) throw new Error('expected renderable');
expect(result.services).toEqual([
{ name: 'web', declaredImage: 'nginx:latest', hasBuild: false, expectedReplicas: 1, dependsOn: ['db'], hasHealthcheck: true },
{ name: 'db', declaredImage: 'postgres:16', hasBuild: false, expectedReplicas: 1, dependsOn: [], hasHealthcheck: false },
]);
});
it('flags hasBuild for a build-backed service, including image+build', async () => {
stubRender(JSON.stringify({
services: {
api: { build: { context: '.' } },
worker: { image: 'app:latest', build: { context: '.' } },
},
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services[0]).toMatchObject({ name: 'api', declaredImage: null, hasBuild: true });
expect(result.services[1]).toMatchObject({ name: 'worker', declaredImage: 'app:latest', hasBuild: true });
});
it('reads expectedReplicas from the top-level scale field, deploy.replicas, or defaults to 1', async () => {
stubRender(JSON.stringify({
services: {
scaled: { image: 'a', scale: 3 },
deployed: { image: 'b', deploy: { replicas: 2 } },
zeroed: { image: 'c', scale: 0 },
unset: { image: 'd' },
},
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services.map(s => s.expectedReplicas)).toEqual([3, 2, 0, 1]);
});
it('treats a disabled healthcheck as none', async () => {
stubRender(JSON.stringify({
services: { web: { image: 'a', healthcheck: { disable: true } } },
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services[0].hasHealthcheck).toBe(false);
});
it('treats test NONE as a disabled healthcheck', async () => {
stubRender(JSON.stringify({
services: { web: { image: 'a', healthcheck: { test: ['NONE'] } } },
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services[0].hasHealthcheck).toBe(false);
});
it('parses depends_on given in the short list form', async () => {
stubRender(JSON.stringify({
services: { web: { image: 'a', depends_on: ['db', 'cache'] } },
}));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services[0].dependsOn).toEqual(['db', 'cache']);
});
it('fails closed with a redacted error and no services when the render errors', async () => {
stubRender(null, `error: the "${ENV_SECRET}" variable is not set`);
const result = await buildEffectiveServiceModel(1, 'mystack');
expect(result.renderable).toBe(false);
if (result.renderable) throw new Error('expected not renderable');
expect(result.code).toBe('effective_model_render_failed');
expect(result.error).not.toContain(ENV_SECRET);
});
it('fails closed when the rendered output is not valid JSON', async () => {
stubRender('this is not json {');
const result = await buildEffectiveServiceModel(1, 'mystack');
expect(result.renderable).toBe(false);
if (result.renderable) throw new Error('expected not renderable');
expect(result.code).toBe('effective_model_render_failed');
});
it('fails closed and redacts the message when renderConfig throws (docker unavailable)', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockRejectedValue(new Error(`spawn failed: password=${ENV_SECRET}`)),
} as unknown as ComposeService);
const result = await buildEffectiveServiceModel(1, 'mystack');
expect(result.renderable).toBe(false);
if (result.renderable) throw new Error('expected not renderable');
expect(result.code).toBe('effective_model_render_failed');
expect(result.error).not.toContain(ENV_SECRET);
});
it('yields an empty service list for a garbage or empty rendered model', async () => {
stubRender(JSON.stringify({ services: {} }));
const result = await buildEffectiveServiceModel(1, 'mystack');
if (!result.renderable) throw new Error('expected renderable');
expect(result.services).toEqual([]);
});
});