mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
38aabe7064
* feat: classify stack deploy and update failures with suggested next actions Failed deploy and update responses now carry a failure classification (cause category, headline, and suggested next step) derived from the compose error output. The recovery panel and chip render the classification and include it in copied diagnostics, and gateway-style failures surface as a node-unreachable cause. * feat: add update and rollback readiness reports for stacks Before a manual update, Sencho now shows an advisory readiness verdict computed from the stored preflight result, open drift findings, live container health, the pending image change, the rollback backup slot, and node disk headroom. The Stack Dossier gains a rollback readiness section that states what a rollback can restore and explicitly discloses that volume and bind-mounted data are not covered. Toolbar and sidebar updates now share one update path, and admins can create a fleet snapshot from the readiness dialog before updating. Nodes that do not advertise the capability keep the direct update flow. * feat: observe stack health after updates with a post-deploy health gate After a deploy or update succeeds, Sencho now watches the stack for a configurable observation window and records a passed, failed, or unknown verdict: containers must stay running, healthchecks must report healthy, and restart loops or disappearing containers fail the gate. The deploy panel shows the observation live and holds off auto-closing until the verdict lands, a failed gate surfaces the existing recovery actions including rollback, and the stack timeline records update started and gate verdict events. Scheduled, webhook, bulk, and git-source updates are gated the same way; rollbacks and installs are deliberately not. The gate is observational only and can be tuned or disabled per node under host alert settings. * docs: document health-gated updates and rollback readiness New operator page covering the update readiness dialog, the post-update health gate and its settings, the rollback readiness disclosure, and classified failures, with cross-links from the atomic deployments and deploy progress pages. The API reference gains the readiness and health-gate endpoints, the healthGateId success field, and the failure classification schema on deploy and update error responses. * feat: withhold the success verdict while the health gate observes An update used to show a green Succeeded that a failed health gate then contradicted moments later. The deploy modal now reports Verifying health while the gate observes, shows success only when the gate passes, and makes a failed or unknown gate the headline result; success toasts soften to a verifying message while a gate runs. The mobile recovery card groups its actions behind one bottom-right Take action menu so it stays compact on a phone, with the classified cause still visible on the card. A successful image update now also counts as the last known-good marker in rollback readiness, and the docs gain screenshots of the readiness dialog, gate states, dossier section, and settings. * fix: harden log format strings and the env existence path check Log calls that interpolated the stack name into the console format string now use constant format strings with placeholder arguments, and envExists validates path containment inline at its filesystem access, matching the established patterns used elsewhere in the same files. * test: adapt deploy modal success specs to the post-deploy health gate The deploy feedback modal now withholds its success verdict while the health gate observes the new containers, showing "Verifying health" until the gate passes. The two success-path E2E tests waited for "Succeeded" within the gate's 90s default window and timed out. Shorten the observation window to the 15s minimum for these tests via the settings API, assert the verify-then-succeed sequence the modal actually renders, and restore the default window afterward so the test value does not leak into later runs. * fix: serialize health gate polling and harden gate observation Address race conditions in the post-update health gate found in review. Backend: the gate poller used setInterval, so a Docker observe slower than the 5s tick could overlap the next poll and corrupt the restart and missing-container accounting, and a wedged socket could leave a poll pending forever. Polling is now single-flight: each cycle self-schedules the next only after it settles, and the observe is bounded by an 8s timeout so a hung probe counts as a poll error and resolves the gate unknown after three in a row. Frontend: the gate poller could overlap requests, letting a slow earlier "observing" response overwrite an already-applied terminal verdict. It is now single-flight with a terminal latch, so a late response can never roll the UI back from passed or failed. Also reject a non-digit nodeId on the snapshot coverage route instead of letting parseInt coerce it, document that turning off the deploy progress panel opts out of the live gate UI while the gate still runs server-side, and add gate-coverage tests for the webhook, git source, and auto-update apply paths plus the new single-flight, observe-timeout, and recovery cases.
561 lines
19 KiB
TypeScript
561 lines
19 KiB
TypeScript
/**
|
|
* Integration tests verifying that deploy_failure notifications are dispatched
|
|
* when stack action routes encounter errors.
|
|
*
|
|
* Covers: deploy, down, restart, stop, update
|
|
*
|
|
* ComposeService and DockerController are mocked so no real Docker daemon is
|
|
* required. NotificationService.dispatchAlert is spied on to assert dispatch.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach, afterEach } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import { ComposeRollbackError } from '../services/ComposeService';
|
|
import * as policyGate from '../helpers/policyGate';
|
|
|
|
// ── Hoisted mocks (must come before importing the app) ──────────────────────
|
|
|
|
const {
|
|
mockDeployStack,
|
|
mockRunCommand,
|
|
mockUpdateStack,
|
|
mockGetContainersByStack,
|
|
mockRestartContainer,
|
|
mockStopContainer,
|
|
mockListContainers,
|
|
mockIsTrivyAvailable,
|
|
mockGetImageDigest,
|
|
mockRunScanAndPersist,
|
|
mockGetBackupInfo,
|
|
mockRestoreStackFiles,
|
|
mockSnapshotStackFiles,
|
|
} = vi.hoisted(() => ({
|
|
mockDeployStack: vi.fn(),
|
|
mockRunCommand: vi.fn(),
|
|
mockUpdateStack: vi.fn(),
|
|
mockGetContainersByStack: vi.fn(),
|
|
mockRestartContainer: vi.fn(),
|
|
mockStopContainer: vi.fn(),
|
|
mockListContainers: vi.fn(),
|
|
mockIsTrivyAvailable: vi.fn(),
|
|
mockGetImageDigest: vi.fn(),
|
|
mockRunScanAndPersist: vi.fn(),
|
|
mockGetBackupInfo: vi.fn(),
|
|
mockRestoreStackFiles: vi.fn(),
|
|
mockSnapshotStackFiles: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/ComposeService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
|
|
'../services/ComposeService',
|
|
);
|
|
return {
|
|
...actual,
|
|
ComposeService: {
|
|
...actual.ComposeService,
|
|
getInstance: () => ({
|
|
deployStack: mockDeployStack,
|
|
runCommand: mockRunCommand,
|
|
updateStack: mockUpdateStack,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/DockerController', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/DockerController')>(
|
|
'../services/DockerController',
|
|
);
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
getInstance: () => ({
|
|
getContainersByStack: mockGetContainersByStack,
|
|
restartContainer: mockRestartContainer,
|
|
stopContainer: mockStopContainer,
|
|
getDocker: () => ({
|
|
listContainers: mockListContainers,
|
|
}),
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/TrivyService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/TrivyService')>(
|
|
'../services/TrivyService',
|
|
);
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
getInstance: () => ({
|
|
isTrivyAvailable: mockIsTrivyAvailable,
|
|
getImageDigest: mockGetImageDigest,
|
|
runScanAndPersist: mockRunScanAndPersist,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getStacks: vi.fn().mockResolvedValue([]),
|
|
getBaseDir: () => '/tmp/compose',
|
|
readComposeFile: vi.fn().mockResolvedValue(''),
|
|
hasComposeFile: vi.fn().mockResolvedValue(true),
|
|
getBackupInfo: mockGetBackupInfo,
|
|
restoreStackFiles: mockRestoreStackFiles,
|
|
snapshotStackFiles: mockSnapshotStackFiles,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// ── Setup ───────────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
|
|
const { NotificationService } = await import('../services/NotificationService');
|
|
dispatchAlertSpy = vi
|
|
.spyOn(NotificationService.getInstance(), 'dispatchAlert')
|
|
.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockDeployStack.mockReset();
|
|
mockRunCommand.mockReset();
|
|
mockUpdateStack.mockReset();
|
|
mockGetContainersByStack.mockReset();
|
|
mockRestartContainer.mockReset();
|
|
mockStopContainer.mockReset();
|
|
mockListContainers.mockReset();
|
|
mockIsTrivyAvailable.mockReset();
|
|
mockGetImageDigest.mockReset();
|
|
mockRunScanAndPersist.mockReset();
|
|
mockIsTrivyAvailable.mockReturnValue(true);
|
|
mockListContainers.mockResolvedValue([{ Image: 'nginx:latest' }]);
|
|
mockGetImageDigest.mockResolvedValue(null);
|
|
mockRunScanAndPersist.mockResolvedValue({
|
|
critical_count: 0,
|
|
high_count: 0,
|
|
});
|
|
mockGetBackupInfo.mockReset();
|
|
mockRestoreStackFiles.mockReset();
|
|
mockSnapshotStackFiles.mockReset();
|
|
mockGetBackupInfo.mockResolvedValue({ exists: true, timestamp: 1 });
|
|
mockRestoreStackFiles.mockResolvedValue(undefined);
|
|
mockSnapshotStackFiles.mockResolvedValue(async () => {});
|
|
dispatchAlertSpy.mockClear();
|
|
});
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('deploy_failure notification on /deploy error', () => {
|
|
it('dispatches deploy_failure alert with correct stackName when deployStack throws', async () => {
|
|
mockDeployStack.mockRejectedValue(new Error('image pull failed'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.stringContaining('image pull failed'),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
});
|
|
|
|
it('includes the error message in the dispatched alert', async () => {
|
|
mockDeployStack.mockRejectedValue(new Error('network timeout'));
|
|
|
|
await request(app)
|
|
.post('/api/stacks/webapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
const call = dispatchAlertSpy.mock.calls[0];
|
|
expect(call[0]).toBe('error');
|
|
expect(call[1]).toBe('deploy_failure');
|
|
expect(call[2]).toContain('network timeout');
|
|
expect(call[3]).toEqual({ stackName: 'webapp', actor: 'testadmin' });
|
|
});
|
|
|
|
it('returns rolledBack=true only when compose rollback completed', async () => {
|
|
mockDeployStack.mockRejectedValue(
|
|
new ComposeRollbackError(new Error('image pull failed'), true, true),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toMatchObject({ rolledBack: true });
|
|
});
|
|
|
|
it('returns rolledBack=false when compose rollback failed', async () => {
|
|
mockDeployStack.mockRejectedValue(
|
|
new ComposeRollbackError(new Error('image pull failed'), true, false),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toMatchObject({ rolledBack: false });
|
|
});
|
|
|
|
it('uses trusted proxy tier headers for remote atomic deploys', async () => {
|
|
mockDeployStack.mockResolvedValue(undefined);
|
|
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.set('x-sencho-tier', 'paid');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockDeployStack.mock.calls[0][2]).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('health gate begin call sites', () => {
|
|
let beginSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(async () => {
|
|
const { HealthGateService } = await import('../services/HealthGateService');
|
|
beginSpy = vi.spyOn(HealthGateService.getInstance(), 'begin').mockReturnValue('gate-123') as ReturnType<typeof vi.spyOn>;
|
|
});
|
|
|
|
afterEach(() => {
|
|
beginSpy.mockRestore();
|
|
});
|
|
|
|
it('begins a gate after a manual deploy and returns its id', async () => {
|
|
mockDeployStack.mockResolvedValue(undefined);
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
expect(res.status).toBe(200);
|
|
expect(beginSpy).toHaveBeenCalledWith(expect.any(Number), 'myapp', 'deploy', 'testadmin');
|
|
expect(res.body.healthGateId).toBe('gate-123');
|
|
});
|
|
|
|
it('begins a gate after a manual update and returns its id', async () => {
|
|
mockUpdateStack.mockResolvedValue(undefined);
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
expect(res.status).toBe(200);
|
|
expect(beginSpy).toHaveBeenCalledWith(expect.any(Number), 'myapp', 'update', 'testadmin');
|
|
expect(res.body.healthGateId).toBe('gate-123');
|
|
});
|
|
|
|
it('begins a gate per stack in a bulk update and carries ids in the results', async () => {
|
|
mockUpdateStack.mockResolvedValue(undefined);
|
|
const res = await request(app)
|
|
.post('/api/stacks/bulk')
|
|
.set('Cookie', authCookie)
|
|
.send({ action: 'update', stackNames: ['myapp', 'webapp'] });
|
|
expect(res.status).toBe(200);
|
|
expect(beginSpy).toHaveBeenCalledWith(expect.any(Number), 'myapp', 'update', 'testadmin');
|
|
expect(beginSpy).toHaveBeenCalledWith(expect.any(Number), 'webapp', 'update', 'testadmin');
|
|
const items = res.body.results as Array<{ stackName: string; ok: boolean; healthGateId?: string | null }>;
|
|
expect(items).toHaveLength(2);
|
|
for (const item of items) {
|
|
expect(item.ok).toBe(true);
|
|
expect(item.healthGateId).toBe('gate-123');
|
|
}
|
|
});
|
|
|
|
it('does not begin a gate on a failed deploy', async () => {
|
|
mockDeployStack.mockRejectedValue(new Error('boom'));
|
|
await request(app).post('/api/stacks/myapp/deploy').set('Cookie', authCookie);
|
|
expect(beginSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never begins a gate for the rollback recovery path', async () => {
|
|
mockDeployStack.mockResolvedValue(undefined);
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/rollback')
|
|
.set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(beginSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('failure classification on deploy/update error responses', () => {
|
|
it('classifies a failed deploy and includes failure in the body', async () => {
|
|
mockDeployStack.mockRejectedValue(
|
|
new Error('Bind for 0.0.0.0:8080 failed: port is already allocated'),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.failure).toMatchObject({ reason: 'port_conflict' });
|
|
expect(typeof res.body.failure.label).toBe('string');
|
|
expect(typeof res.body.failure.suggestion).toBe('string');
|
|
});
|
|
|
|
it('classifies a failed update and includes failure in the body', async () => {
|
|
mockUpdateStack.mockRejectedValue(
|
|
new Error('pull access denied for private/app, repository does not exist'),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.failure).toMatchObject({ reason: 'image_pull_failed' });
|
|
});
|
|
|
|
it('classifies the underlying cause when the update was rolled back', async () => {
|
|
mockUpdateStack.mockRejectedValue(
|
|
new ComposeRollbackError(
|
|
new Error('dependency failed to start: container app-db-1 is unhealthy'),
|
|
true,
|
|
true,
|
|
),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toMatchObject({
|
|
rolledBack: true,
|
|
failure: { reason: 'healthcheck_failed' },
|
|
});
|
|
});
|
|
|
|
it('falls back to unknown for unrecognized failures', async () => {
|
|
mockDeployStack.mockRejectedValue(new Error('weird one-off explosion'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.failure.reason).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('post-deploy scan opt-out', () => {
|
|
it('does not trigger a post-deploy scan when skip_scan is true', async () => {
|
|
mockDeployStack.mockResolvedValue(undefined);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/deploy')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(200);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockListContainers).not.toHaveBeenCalled();
|
|
expect(mockRunScanAndPersist).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('deploy_failure notification on /down error', () => {
|
|
it('dispatches deploy_failure alert when runCommand (down) throws', async () => {
|
|
mockRunCommand.mockRejectedValue(new Error('container removal error'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/down')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.any(String),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deploy_failure notification on /restart error', () => {
|
|
it('dispatches deploy_failure alert when restartContainer throws', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
|
mockRestartContainer.mockRejectedValue(new Error('restart daemon error'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/restart')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.stringContaining('restart daemon error'),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deploy_failure notification on /stop error', () => {
|
|
it('dispatches deploy_failure alert when stopContainer throws', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
|
mockStopContainer.mockRejectedValue(new Error('stop daemon error'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/stop')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.stringContaining('stop daemon error'),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deploy_failure notification on /update error', () => {
|
|
it('dispatches deploy_failure alert with correct stackName when updateStack throws', async () => {
|
|
mockUpdateStack.mockRejectedValue(new Error('image not found'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.stringContaining('image not found'),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
});
|
|
|
|
it('returns rollback completion status when updateStack throws rollback metadata', async () => {
|
|
mockUpdateStack.mockRejectedValue(
|
|
new ComposeRollbackError(new Error('image not found'), true, false),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toMatchObject({ rolledBack: false });
|
|
});
|
|
|
|
it('uses trusted proxy tier headers for remote atomic updates', async () => {
|
|
mockUpdateStack.mockResolvedValue(undefined);
|
|
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/update')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.set('x-sencho-tier', 'paid');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockUpdateStack.mock.calls[0][2]).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('rollback file-revert safety on a policy-blocked rollback', () => {
|
|
it('does not deploy and alerts the operator when the post-block file revert fails', async () => {
|
|
// The restored backup is blocked by policy after files were already restored.
|
|
const gateSpy = vi
|
|
.spyOn(policyGate, 'runPolicyGate')
|
|
.mockImplementation(async (_req, res) => {
|
|
res.status(409).json({ error: 'Rollback blocked by policy' });
|
|
return false;
|
|
});
|
|
// The revert that should undo the restore itself fails (e.g. EACCES on a
|
|
// chowned bind mount), leaving disk inconsistent with the deployed stack.
|
|
mockSnapshotStackFiles.mockResolvedValue(async () => {
|
|
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
});
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/rollback')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(409);
|
|
// The rollback must not have deployed the blocked target.
|
|
expect(mockDeployStack).not.toHaveBeenCalled();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
// The revert failure is escalated on the persistent alert feed.
|
|
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
|
'error',
|
|
'deploy_failure',
|
|
expect.stringContaining('EACCES'),
|
|
{ stackName: 'myapp', actor: 'testadmin' },
|
|
);
|
|
} finally {
|
|
gateSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('reverts cleanly and stays quiet when the policy block revert succeeds', async () => {
|
|
const revert = vi.fn().mockResolvedValue(undefined);
|
|
const gateSpy = vi
|
|
.spyOn(policyGate, 'runPolicyGate')
|
|
.mockImplementation(async (_req, res) => {
|
|
res.status(409).json({ error: 'Rollback blocked by policy' });
|
|
return false;
|
|
});
|
|
mockSnapshotStackFiles.mockResolvedValue(revert);
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/stacks/myapp/rollback')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(revert).toHaveBeenCalledTimes(1);
|
|
expect(mockDeployStack).not.toHaveBeenCalled();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
expect(dispatchAlertSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
gateSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|