Files
sencho/backend/src/__tests__/stack-self-protected-routes.test.ts
T
Anso 3f1f15a6f4 fix: keep running containers until stack pull/build succeeds (#1657)
* fix: keep running containers until stack pull/build succeeds

Acquire images before reconcile, capture a recovery generation for
compensation, and only remove classified orphans after handoff.

* fix: address recovery audit blockers for safe stack updates

Retire abandoned and expired recovery artifacts, probe compensated
runtimes before reporting rollback success, preserve local Docker when
deleting a node, validate the exact Compose invocation before capture,
and repair updateStack return-contract fixtures.

* fix: resolve ESLint errors blocking CI on this branch

Unused-import and unused-variable errors left over from the stack
deletion refactor: MeshService in stacks.ts (its opt-out cascade moved
into DeployedStackDeletionService), a redundant pruneVolumes
destructure in deleteDeployedStack (the real one is re-derived from
the same input object inside runDeletionBody), and an unused beforeAll
import in a Docker-integration test stub. Also scopes the webhook
pull-action case body in a block to satisfy no-case-declarations;
purely syntactic, no behavior change.

* fix: harden recovery probe, cleanup retry, and failed-pull Docker test

Reject absent or unhealthy expected replicas before reporting rollback
success, keep cleanup records until artifacts are actually removed, fail
closed when a mesh override cannot be generated, and assert a real
failed pull leaves the original container running.

* fix: verify recovery probe image identity and stack-scoped override paths

Reject recovered runtimes that use the wrong image or leave scale-zero
services running, and confine tombstone override deletion to the intent
stack directory so forged cross-stack paths cannot be swept.

* test: batch notification cap fixtures in a SQLite transaction

Unbatched 1200-row inserts were timing out at the default 30s under
CI load even though the same assertions pass in under 2s when green.
2026-07-21 12:18:01 -04:00

226 lines
7.3 KiB
TypeScript

/**
* Route-level tests for self-stack lifecycle protection. When Sencho's compose
* project is discovered as a managed stack, destructive lifecycle endpoints
* return 409 self_stack_protected instead of recreating or removing the instance.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockDownStack,
mockGetContainersByStack,
mockStopContainer,
mockRestartContainer,
mockGetBulkStackStatuses,
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockDownStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockStopContainer: vi.fn(),
mockRestartContainer: vi.fn(),
mockGetBulkStackStatuses: 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,
runDown: mockRunDown,
updateStack: mockUpdateStack,
downStack: mockDownStack,
}),
},
};
});
vi.mock('../services/DockerController', async () => {
const actual = await vi.importActual<typeof import('../services/DockerController')>(
'../services/DockerController',
);
return {
...actual,
default: {
...actual.default,
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
stopContainer: mockStopContainer,
restartContainer: mockRestartContainer,
getBulkStackStatuses: mockGetBulkStackStatuses,
}),
},
};
});
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
function writeStack(name: string) {
const dir = path.join(process.env.COMPOSE_DIR!, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
}
function stubSelfProject(projectName: string | null) {
const svc = SelfIdentityService.getInstance();
vi.spyOn(svc, 'initialize').mockResolvedValue(undefined);
vi.spyOn(svc, 'getIdentity').mockReturnValue({
containerId: 'a'.repeat(64),
containerName: 'sencho',
composeProjectName: projectName,
imageId: 'b'.repeat(64),
networkNames: [],
volumeNames: [],
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ default: SelfIdentityService } = await import('../services/SelfIdentityService'));
authCookie = await loginAsTestAdmin(app);
writeStack('sencho');
writeStack('web');
const { NotificationService } = await import('../services/NotificationService');
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
afterEach(async () => {
vi.clearAllMocks();
SelfIdentityService.getInstance().resetForTesting();
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]);
mockRestartContainer.mockResolvedValue(undefined);
mockStopContainer.mockResolvedValue(undefined);
mockGetBulkStackStatuses.mockResolvedValue({
sencho: { status: 'running' },
web: { status: 'running' },
});
const { StackOpLockService } = await import('../services/StackOpLockService');
StackOpLockService.resetForTests();
});
describe('self stack lifecycle refusal', () => {
beforeEach(() => {
stubSelfProject('sencho');
});
const protectedEndpoints = [
['POST', '/api/stacks/sencho/deploy'],
['POST', '/api/stacks/sencho/update'],
['POST', '/api/stacks/sencho/down'],
['POST', '/api/stacks/sencho/stop'],
['POST', '/api/stacks/sencho/rollback'],
['POST', '/api/stacks/sencho/services/web/stop'],
['DELETE', '/api/stacks/sencho'],
] as const;
it.each(protectedEndpoints)('%s %s returns 409 self_stack_protected', async (method, url) => {
const res = method === 'DELETE'
? await request(app).delete(url).set('Cookie', authCookie)
: await request(app).post(url).set('Cookie', authCookie);
expect(res.status).toBe(409);
expect(res.body.code).toBe(SELF_STACK_PROTECTED_CODE);
expect(mockDeployStack).not.toHaveBeenCalled();
expect(mockUpdateStack).not.toHaveBeenCalled();
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockRunDown).not.toHaveBeenCalled();
expect(mockDownStack).not.toHaveBeenCalled();
expect(mockStopContainer).not.toHaveBeenCalled();
});
it('allows restart on the self stack', async () => {
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]);
const res = await request(app)
.post('/api/stacks/sencho/restart')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
});
it('allows update on a non-self stack', async () => {
mockUpdateStack.mockResolvedValue({ recoveryId: null });
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockUpdateStack).toHaveBeenCalledWith('web', undefined, true);
});
});
describe('POST /api/stacks/bulk self stack skip', () => {
beforeEach(() => {
stubSelfProject('sencho');
mockUpdateStack.mockResolvedValue({ recoveryId: null });
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]);
});
it('returns self_stack_protected for update/stop on the self stack but allows restart', async () => {
const updateRes = await request(app)
.post('/api/stacks/bulk')
.set('Cookie', authCookie)
.send({ action: 'update', stackNames: ['sencho', 'web'] });
expect(updateRes.status).toBe(200);
const updateRow = updateRes.body.results.find((r: { stackName: string }) => r.stackName === 'sencho');
const webRow = updateRes.body.results.find((r: { stackName: string }) => r.stackName === 'web');
expect(updateRow.ok).toBe(false);
expect(updateRow.code).toBe(SELF_STACK_PROTECTED_CODE);
expect(webRow.ok).toBe(true);
const stopRes = await request(app)
.post('/api/stacks/bulk')
.set('Cookie', authCookie)
.send({ action: 'stop', stackNames: ['sencho'] });
expect(stopRes.body.results[0].code).toBe(SELF_STACK_PROTECTED_CODE);
const restartRes = await request(app)
.post('/api/stacks/bulk')
.set('Cookie', authCookie)
.send({ action: 'restart', stackNames: ['sencho'] });
expect(restartRes.body.results[0].ok).toBe(true);
});
});
describe('GET /api/stacks/statuses isSelf flag', () => {
it('marks the self stack in the statuses payload', async () => {
stubSelfProject('sencho');
const res = await request(app)
.get('/api/stacks/statuses')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
const senchoKey = Object.keys(res.body).find(k => k.replace(/\.(yml|yaml)$/, '') === 'sencho');
expect(senchoKey).toBeDefined();
expect(res.body[senchoKey!].isSelf).toBe(true);
});
});