mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
3f1f15a6f4
* 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.
226 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|