Files
dashcaddy/dashcaddy-api/__tests__/bundled-workflows-health-check.test.js
T
Hermes 97672f7e74
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=A] fix(notifications): un-gate 7 dead emitters + repair legacy 4-arg send shape (DC-094)
Two silent-death defects in one class:

1. Seven emitters absent from DEFAULT events, so the send() gate
   (config.events[canonical] !== true) dropped them on every install:
   ssl-cert-expiry, dns-propagation, drift-detected,
   dependency-restart-complete/-failed, recipeRemoved, workflow.
   All now default ON; dependency-restart spellings fold onto one
   canonical toggle; recipeRemoved aliases to recipe-removed in both
   the manager and route alias maps.

2. Nine call sites used a legacy 4-arg send(event, title, message, type)
   against the 3-arg signature: the message string landed in the type
   slot (Discord embed color fell back to info-blue, history.type wrong)
   and providers received the TITLE as the body - deploy-failure
   notifications carried no error text at all. Fixed at source (9 sites)
   plus a type-guarded shim in send() for external legacy callers.

Also: explicit data.title now flows to ntfy Title header, email subject,
Discord embed title, and history; settings UI gains 9 event toggles
(separate Backup Complete/Failed) with defaults-on semantics.

Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding
send-time and load-time, stored-config inheritance, shim body/title/
color/subject/history, type-guard, 3-arg no-regression); 4 assertions in
bundled-workflows-health-check updated from the old 4-arg mock contract
to the canonical shape (same behavior asserted). Full suite 116 suites /
2706 tests green.

Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A,
zero blockers; 2 polish items (Backups toggle conflation, shim
type-guard) folded into this commit. Verdict URN:
urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
2026-08-22 19:42:25 -07:00

401 lines
15 KiB
JavaScript

/**
* Regression tests for WorkflowEngine.healthCheckService (DC-042 followup).
*
* Bug: bundled-workflows.js:310 called `servicesStateManager.getState()` —
* a method that doesn't exist on StateManager. Combined with a missing
* `await`, this returned a Promise instead of an array, which then short-
* circuited via `|| []` to an empty array. The result: every health-check-
* on-interval workflow ran successfully with 0 services checked, while
* the workflow engine still reported "Action health-check failed:
* servicesStateManager.getState is not a function" on the dashboard.
*
* Fix: call `await servicesStateManager.read()` with a .catch fallback to
* an empty array so a corrupt/missing state file doesn't break the
* workflow.
*/
const { WorkflowEngine } = require('../src/recipes/bundled-workflows');
function makeEngine(opts = {}) {
const ctx = {
servicesStateManager: opts.servicesStateManager || {
read: jest.fn().mockResolvedValue([]),
},
docker: opts.docker !== undefined ? opts.docker : {
client: {
getContainer: jest.fn(),
},
},
};
const engine = new WorkflowEngine(ctx);
// The constructor calls startScheduledWorkflows() which sets setInterval jobs.
// Those prevent Jest from exiting cleanly. Clear them after construction.
// We only care about healthCheckService behavior here, not scheduling.
if (engine.scheduledJobs) {
for (const job of engine.scheduledJobs.values()) {
clearInterval(job);
}
engine.scheduledJobs.clear();
}
return engine;
}
describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)', () => {
test('uses .read() not the non-existent .getState() — does not throw', async () => {
const readMock = jest.fn().mockResolvedValue([]);
const engine = makeEngine({
servicesStateManager: { read: readMock },
docker: undefined, // no docker — exercises the falsy branch
});
// The original bug: this throws `servicesStateManager.getState is not a function`
const result = await engine.healthCheckService('{{serviceId}}');
expect(readMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
});
test('returns checked/healthy counts from read() output (all healthy)', async () => {
const docker = {
client: {
getContainer: jest.fn((id) => ({
inspect: jest.fn().mockResolvedValue({
State: { Running: true, Health: { Status: 'healthy' } },
}),
})),
},
};
const engine = makeEngine({
servicesStateManager: {
read: jest.fn().mockResolvedValue([
{ id: 'svc-1', containerId: 'c1' },
{ id: 'svc-2', containerId: 'c2' },
{ id: 'svc-3' }, // no containerId, should be skipped
]),
},
docker,
});
const result = await engine.healthCheckService('{{serviceId}}');
expect(result.checked).toBe(2); // svc-3 skipped (no containerId)
expect(result.healthy).toBe(2); // both containers healthy
expect(result.results).toHaveLength(2);
expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true });
expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true });
expect(result.failing).toEqual([]);
});
test('throws when any service is unhealthy — surfaces failing service IDs', async () => {
const docker = {
client: {
getContainer: jest.fn((id) => ({
inspect: jest.fn().mockResolvedValue({
State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } },
}),
})),
},
};
const engine = makeEngine({
servicesStateManager: {
read: jest.fn().mockResolvedValue([
{ id: 'svc-1', containerId: 'c1' },
{ id: 'svc-2', containerId: 'c2' },
]),
},
docker,
});
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/);
await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({
failingServices: ['svc-2'],
workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }),
});
});
test('gracefully degrades if read() throws — empty services list, no crash', async () => {
const engine = makeEngine({
servicesStateManager: {
read: jest.fn().mockRejectedValue(new Error('disk on fire')),
},
docker: undefined,
});
// Before the fix, this rejected because .read() wasn't called and the
// .catch(() => []) fallback didn't exist. Now it should resolve to empty.
const result = await engine.healthCheckService('{{serviceId}}');
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
});
test('servicesStateManager absent on ctx → no crash, empty result', async () => {
const engine = new WorkflowEngine({
servicesStateManager: null,
docker: undefined,
});
// Same constructor cleanup
if (engine.scheduledJobs) {
for (const job of engine.scheduledJobs.values()) clearInterval(job);
engine.scheduledJobs.clear();
}
const result = await engine.healthCheckService('{{serviceId}}');
expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
});
test('single service (non-template serviceId) path still works', async () => {
const engine = makeEngine({
docker: {
client: {
getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
})),
},
},
});
const result = await engine.healthCheckService('single-svc-id');
expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true });
});
test('single-service check throws when container is unhealthy', async () => {
const engine = makeEngine({
docker: {
client: {
getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
})),
},
},
});
await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({
failingServices: ['down-svc'],
});
});
});
/**
* DC-044 root-cause fix tests: notify-on-failure gating + template interpolation.
*
* The original code in executeAction had TWO latent bugs:
* 1. notify-on-failure sent unconditionally (its comment said "Only send if
* previous action failed" but the code never checked).
* 2. healthCheckService returned no serviceId field, so templates like
* `Health check failed for {{serviceId}}` never interpolated and stayed
* literal in every alert.
*
* These tests exercise the full executeWorkflow path with a stub workflow
* that pairs `health-check` with `notify-on-failure`.
*/
describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => {
// Build an engine and call _runActions directly with arbitrary action
// sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and
// don't mutate module state.
function makeEngine(opts = {}) {
const ctx = {
servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) },
docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } },
notification: opts.notification || { send: jest.fn() },
};
const engine = new WorkflowEngine(ctx);
if (engine.scheduledJobs) {
for (const job of engine.scheduledJobs.values()) clearInterval(job);
engine.scheduledJobs.clear();
}
return engine;
}
test('notify-on-failure is a no-op when the previous action succeeded', async () => {
const notify = jest.fn();
const engine = makeEngine({
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) },
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
})) } },
notification: { send: notify },
});
const results = await engine._runActions(
[
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
],
{ trigger: 'manual' }
);
const notifyResult = results.find(r => r.action === 'notify-on-failure');
expect(notifyResult.success).toBe(true);
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
expect(notify).not.toHaveBeenCalled();
});
test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => {
const notify = jest.fn();
const engine = makeEngine({
servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) },
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
})) } },
notification: { send: notify },
});
const results = await engine._runActions(
[
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
],
{ trigger: 'manual' }
);
const healthResult = results.find(r => r.action === 'health-check');
const notifyResult = results.find(r => r.action === 'notify-on-failure');
expect(healthResult.success).toBe(false);
expect(healthResult.failingServices).toEqual(['svc-broken']);
expect(notifyResult.success).toBe(true);
expect(notify).toHaveBeenCalledTimes(1);
// DC-094 notification.send signature: (event, { title, text }, level)
const sentMessage = notify.mock.calls[0][1].text;
expect(sentMessage).toBe('Health check failed for svc-broken');
expect(sentMessage).not.toContain('{{');
});
test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => {
const notify = jest.fn();
const engine = makeEngine({ notification: { send: notify } });
const results = await engine._runActions(
[{ type: 'notify', message: 'always sent' }],
{ trigger: 'manual' }
);
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][1].text).toBe('always sent');
expect(results[0].success).toBe(true);
});
test('notify-on-failure as first action is a no-op (no previous result)', async () => {
const notify = jest.fn();
const engine = makeEngine({ notification: { send: notify } });
const results = await engine._runActions(
[{ type: 'notify-on-failure', message: 'should not fire' }],
{ trigger: 'manual' }
);
const notifyResult = results[0];
expect(notifyResult.success).toBe(true);
expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
expect(notify).not.toHaveBeenCalled();
});
test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => {
const notify = jest.fn();
const engine = makeEngine({
servicesStateManager: { read: jest.fn().mockResolvedValue([
{ id: 'svc-ok', containerId: 'c1' },
{ id: 'svc-broken-1', containerId: 'c2' },
{ id: 'svc-broken-2', containerId: 'c3' },
]) },
docker: { client: { getContainer: jest.fn((id) => ({
inspect: jest.fn().mockResolvedValue({
State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } },
}),
})) } },
notification: { send: notify },
});
const results = await engine._runActions(
[
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Failing: {{failingServices}}' },
],
{ trigger: 'manual' }
);
expect(notify).toHaveBeenCalledTimes(1);
const sentMessage = notify.mock.calls[0][1].text;
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
});
// B2 regression: hit the actual bundled health-check-on-interval workflow
// end-to-end via executeWorkflow. The bundled template uses
// {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which
// never resolved because no per-service ID is in workflow scope. This test
// would have failed with the old template.
test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => {
const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows');
expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined();
const notify = jest.fn();
const engine = makeEngine({
servicesStateManager: { read: jest.fn().mockResolvedValue([
{ id: 'svc-broken', containerId: 'c1' },
]) },
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({
State: { Running: false, Health: { Status: 'unhealthy' } },
}),
})) } },
notification: { send: notify },
});
const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' });
// Either the bundled workflow fired notification (with interpolated
// message) OR every action resolved — but in NO case may a literal
// {{...}} template token leak into notification.send.
if (notify.mock.calls.length > 0) {
const sentMessage = notify.mock.calls[0][1].text;
expect(sentMessage).not.toMatch(/\{\{/);
expect(sentMessage).not.toMatch(/\}\}/);
// The new bundled template substitutes failingServices — make sure
// the actual service ID made it through.
expect(sentMessage).toContain('svc-broken');
}
// Workflow must always complete (success or failure), never throw.
expect(result).toBeDefined();
expect(result.workflowId).toBe('health-check-on-interval');
});
// B3 regression: a running container with Health.Status === 'unhealthy'
// must be reported as unhealthy. Previously checkContainerHealth compared
// info.State.Health itself (an object) to the string 'unhealthy', which
// was always false — so any container with an explicit healthcheck was
// always considered healthy. The fix reads info.State.Health.Status.
test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => {
const engine = makeEngine({
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({
State: { Running: true, Health: { Status: 'unhealthy' } },
}),
})) } },
});
const healthy = await engine.checkContainerHealth('running-but-unhealthy');
expect(healthy).toBe(false);
});
test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => {
const engine = makeEngine({
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
})) } },
});
const healthy = await engine.checkContainerHealth('no-healthcheck');
expect(healthy).toBe(true);
});
test('checkContainerHealth treats stopped container as unhealthy', async () => {
const engine = makeEngine({
docker: { client: { getContainer: jest.fn(() => ({
inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
})) } },
});
const healthy = await engine.checkContainerHealth('stopped');
expect(healthy).toBe(false);
});
});