mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +00:00
feat: add confirmed Take down stack action with optional volume removal (#1599)
* feat: add confirmed Take down stack action with optional volume removal Expose Take down in the stack header and sidebar with a confirmation dialog that runs compose down while keeping the stack definition on disk. Optional volume removal is gated by node capability and stack:deploy permission, with remote gateway preflight before proxying removeVolumes requests. Closes #1582 * fix: reset take-down volume checkbox when dialog closes * test: align getStackMenuVisibility assertions with showTakeDown key getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown, but three exhaustive toEqual assertions still listed only the prior four keys and failed. Add the expected showTakeDown value to each: true for the partial and exited running-stack cases, false for the self stack. * test: cover Take down visibility for running non-self stacks The getStackMenuVisibility assertions exercised the partial and exited branches and the self-stack guard, but not the raw === 'running' literal that drives showTakeDown for a normal running stack. Add a case so a regression dropping 'running' from that check is caught. * fix: drop Take down from header overflow and wire activity shortcut Remove duplicate Take down from More actions. Keep inline button when running, sidebar menu, and Cmd+ArrowDown. Record stack_taken_down in activity on successful POST /down.
This commit is contained in:
@@ -57,7 +57,7 @@ describe('getAuditSummary()', () => {
|
||||
});
|
||||
|
||||
it('resolves wildcard match: POST /stacks/mystack/down', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack');
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Took stack down: mystack');
|
||||
});
|
||||
|
||||
it('resolves wildcard match: POST /stacks/mystack/rollback', () => {
|
||||
|
||||
@@ -1093,6 +1093,49 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
|
||||
// ── downStack ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - runDown', () => {
|
||||
it('runs plain docker compose down by default', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await svc.runDown('my-stack');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('runs docker compose down --volumes when removeVolumes is true', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await svc.runDown('my-stack', { removeVolumes: true });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down', '--volumes'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass --volumes when removeVolumes is false', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await svc.runDown('my-stack', { removeVolumes: false });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── downStack ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - downStack', () => {
|
||||
it('runs docker compose down with volumes and remove-orphans', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Gateway preflight for POST /stacks/:name/down?removeVolumes=true on remote nodes.
|
||||
* Unsupported remotes must return 400 before the proxy forwards the request.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'http';
|
||||
import bcrypt from 'bcrypt';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let viewerBearer: string;
|
||||
let adminBearer: string;
|
||||
let capServer: http.Server;
|
||||
let noCapServer: http.Server;
|
||||
let capNodeId: number;
|
||||
let noCapNodeId: number;
|
||||
|
||||
const noCapPaths: string[] = [];
|
||||
const capPaths: string[] = [];
|
||||
|
||||
function metaServer(capabilities: string[], seen: string[]): http.Server {
|
||||
return http.createServer((req, res) => {
|
||||
if (req.url) seen.push(req.url);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
if (req.url?.startsWith('/api/meta')) {
|
||||
res.end(JSON.stringify({ version: '0.93.0', capabilities }));
|
||||
} else {
|
||||
res.end(JSON.stringify({ status: 'Command started' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: http.Server): Promise<number> {
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return (server.address() as import('net').AddressInfo).port;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
db.addUser({ username: 'vol-viewer', password_hash: hash, role: 'viewer' });
|
||||
const viewer = db.getUserByUsername('vol-viewer')!;
|
||||
viewerBearer = jwt.sign({ username: 'vol-viewer', role: 'viewer', tv: viewer.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
adminBearer = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
capServer = metaServer(['cross-node-rbac', 'stack-down-remove-volumes'], capPaths);
|
||||
noCapServer = metaServer(['cross-node-rbac'], noCapPaths);
|
||||
const capPort = await listen(capServer);
|
||||
const noCapPort = await listen(noCapServer);
|
||||
|
||||
capNodeId = db.addNode({
|
||||
name: 'vol-cap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
|
||||
is_default: false, api_url: `http://127.0.0.1:${capPort}`, api_token: 'cap-token',
|
||||
});
|
||||
noCapNodeId = db.addNode({
|
||||
name: 'vol-nocap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
|
||||
is_default: false, api_url: `http://127.0.0.1:${noCapPort}`, api_token: 'nocap-token',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => capServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => noCapServer.close(() => resolve()));
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('remote proxy stack-down volume gate', () => {
|
||||
it('returns 400 for removeVolumes=true when remote lacks stack-down-remove-volumes (admin)', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not supported/i);
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
|
||||
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 for removeVolumes=true when remote lacks capability (non-admin)', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Authorization', `Bearer ${viewerBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not supported/i);
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
|
||||
});
|
||||
|
||||
it('proxies removeVolumes=true when remote advertises stack-down-remove-volumes', async () => {
|
||||
capPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(capNodeId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not preflight plain down without removeVolumes', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
|
||||
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { RemoteMeta } from '../services/CapabilityRegistry';
|
||||
|
||||
let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac;
|
||||
let remoteAdvertisesCapability: typeof import('../helpers/remoteCapabilities').remoteAdvertisesCapability;
|
||||
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -17,7 +18,7 @@ const NODE_ID = 4242;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities'));
|
||||
({ remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } = await import('../helpers/remoteCapabilities'));
|
||||
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
||||
});
|
||||
|
||||
@@ -79,3 +80,23 @@ describe('remoteSupportsCrossNodeRbac', () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remoteAdvertisesCapability', () => {
|
||||
it('returns true when the remote advertises the requested capability', async () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||
.mockResolvedValue({ version: '0.93.0', capabilities: ['stack-down-remove-volumes'], ...ONLINE });
|
||||
expect(await remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes')).toBe(true);
|
||||
});
|
||||
|
||||
it('dedupes concurrent probes per node+capability, not per node alone', async () => {
|
||||
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
|
||||
.mockResolvedValue({ version: '0.93.0', capabilities: ['cross-node-rbac', 'stack-down-remove-volumes'], ...ONLINE });
|
||||
const [rbac, volumes] = await Promise.all([
|
||||
remoteAdvertisesCapability(NODE_ID, 'cross-node-rbac'),
|
||||
remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes'),
|
||||
]);
|
||||
expect(rbac).toBe(true);
|
||||
expect(volumes).toBe(true);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,12 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
|
||||
const {
|
||||
mockDeployStack,
|
||||
mockRunCommand,
|
||||
mockRunDown,
|
||||
mockUpdateStack,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
mockRunDown: vi.fn(),
|
||||
mockUpdateStack: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -36,6 +38,7 @@ vi.mock('../services/ComposeService', async () => {
|
||||
getInstance: () => ({
|
||||
deployStack: mockDeployStack,
|
||||
runCommand: mockRunCommand,
|
||||
runDown: mockRunDown,
|
||||
updateStack: mockUpdateStack,
|
||||
}),
|
||||
},
|
||||
@@ -72,8 +75,8 @@ describe('POST /api/stacks/:stackName/deploy on a nonexistent stack', () => {
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
|
||||
it('returns 404 with "Stack not found" and never enters ComposeService.runCommand', async () => {
|
||||
mockRunCommand.mockClear();
|
||||
it('returns 404 with "Stack not found" and never enters ComposeService.runDown', async () => {
|
||||
mockRunDown.mockClear();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/does-not-exist-f7/down')
|
||||
@@ -81,7 +84,7 @@ describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Stack not found' });
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
expect(mockRunDown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isDockerUnavailableError } from '../routes/stacks';
|
||||
const {
|
||||
mockDeployStack,
|
||||
mockRunCommand,
|
||||
mockRunDown,
|
||||
mockUpdateStack,
|
||||
mockGetContainersByStack,
|
||||
mockRestartContainer,
|
||||
@@ -26,6 +27,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
mockRunDown: vi.fn(),
|
||||
mockUpdateStack: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn(),
|
||||
mockRestartContainer: vi.fn(),
|
||||
@@ -44,6 +46,7 @@ vi.mock('../services/ComposeService', async () => {
|
||||
getInstance: () => ({
|
||||
deployStack: mockDeployStack,
|
||||
runCommand: mockRunCommand,
|
||||
runDown: mockRunDown,
|
||||
updateStack: mockUpdateStack,
|
||||
}),
|
||||
},
|
||||
@@ -98,6 +101,7 @@ afterAll(() => {
|
||||
beforeEach(() => {
|
||||
mockDeployStack.mockReset();
|
||||
mockRunCommand.mockReset();
|
||||
mockRunDown.mockReset();
|
||||
mockUpdateStack.mockReset();
|
||||
mockGetContainersByStack.mockReset();
|
||||
mockRestartContainer.mockReset();
|
||||
@@ -211,8 +215,8 @@ describe('POST /api/stacks/:name/deploy with daemon down', () => {
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:name/down with daemon down', () => {
|
||||
it('returns 503 with code: docker_unavailable when runCommand surfaces the daemon error', async () => {
|
||||
mockRunCommand.mockRejectedValue(composeDaemonDownError());
|
||||
it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => {
|
||||
mockRunDown.mockRejectedValue(composeDaemonDownError());
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down')
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Route tests for POST /api/stacks/:name/down and optional ?removeVolumes=true.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
import {
|
||||
disableCapability,
|
||||
enableCapability,
|
||||
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
|
||||
} from '../services/CapabilityRegistry';
|
||||
|
||||
const { mockRunDown } = vi.hoisted(() => ({
|
||||
mockRunDown: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ComposeService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
|
||||
'../services/ComposeService',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ComposeService: {
|
||||
...actual.ComposeService,
|
||||
getInstance: () => ({
|
||||
runDown: mockRunDown,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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 createDeployOnlyToken(): string {
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addApiToken({
|
||||
token_hash: tokenHash,
|
||||
name: `deploy-only-down-${Date.now()}`,
|
||||
scope: 'deploy-only',
|
||||
user_id: db.getUserByUsername('testadmin')!.id,
|
||||
created_at: Date.now(),
|
||||
expires_at: null,
|
||||
});
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
writeStack('web');
|
||||
|
||||
const { NotificationService } = await import('../services/NotificationService');
|
||||
dispatchAlertSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRunDown.mockReset();
|
||||
mockRunDown.mockResolvedValue(undefined);
|
||||
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
||||
const { StackOpLockService } = await import('../services/StackOpLockService');
|
||||
StackOpLockService.resetForTests();
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:name/down removeVolumes', () => {
|
||||
it('runs plain down when removeVolumes is omitted', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
||||
});
|
||||
|
||||
it('dispatches stack_taken_down activity on plain down success', async () => {
|
||||
dispatchAlertSpy.mockClear();
|
||||
mockRunDown.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'stack_taken_down',
|
||||
'web taken down',
|
||||
expect.objectContaining({ stackName: 'web', actor: 'testadmin' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('notes volume removal in the activity message when removeVolumes=true', async () => {
|
||||
dispatchAlertSpy.mockClear();
|
||||
mockRunDown.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'stack_taken_down',
|
||||
'web taken down (volumes removed)',
|
||||
expect.objectContaining({ stackName: 'web' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs plain down when removeVolumes=false', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=false')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
||||
});
|
||||
|
||||
it('does not enable volumes when removeVolumes=1 (only exact true)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=1')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
|
||||
});
|
||||
|
||||
it('returns 400 when removeVolumes=true but capability is absent locally', async () => {
|
||||
disableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not supported/i);
|
||||
expect(mockRunDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes removeVolumes=true to runDown when capability is present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
|
||||
});
|
||||
|
||||
it('allows deploy-only API tokens to POST /down?removeVolumes=true', async () => {
|
||||
const token = createDeployOnlyToken();
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/down?removeVolumes=true')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.body.code).not.toBe('SCOPE_DENIED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
|
||||
const {
|
||||
mockDeployStack,
|
||||
mockRunCommand,
|
||||
mockRunDown,
|
||||
mockUpdateStack,
|
||||
mockGetContainersByStack,
|
||||
mockRestartContainer,
|
||||
@@ -25,6 +26,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
mockRunDown: vi.fn(),
|
||||
mockUpdateStack: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn(),
|
||||
mockRestartContainer: vi.fn(),
|
||||
@@ -43,6 +45,7 @@ vi.mock('../services/ComposeService', async () => {
|
||||
getInstance: () => ({
|
||||
deployStack: mockDeployStack,
|
||||
runCommand: mockRunCommand,
|
||||
runDown: mockRunDown,
|
||||
updateStack: mockUpdateStack,
|
||||
}),
|
||||
},
|
||||
@@ -97,6 +100,7 @@ afterAll(() => {
|
||||
beforeEach(async () => {
|
||||
mockDeployStack.mockReset();
|
||||
mockRunCommand.mockReset();
|
||||
mockRunDown.mockReset();
|
||||
mockUpdateStack.mockReset();
|
||||
mockGetContainersByStack.mockReset();
|
||||
mockRestartContainer.mockReset();
|
||||
@@ -233,13 +237,13 @@ describe('Stack lifecycle mutex', () => {
|
||||
|
||||
it('blocks update while down is in flight', async () => {
|
||||
const gate = deferred<void>();
|
||||
mockRunCommand.mockImplementationOnce(() => gate.promise);
|
||||
mockRunDown.mockImplementationOnce(() => gate.promise);
|
||||
|
||||
const down = request(app)
|
||||
.post('/api/stacks/web/down')
|
||||
.set('Cookie', authCookie)
|
||||
.then(r => r);
|
||||
await vi.waitFor(() => expect(mockRunCommand).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(mockRunDown).toHaveBeenCalled());
|
||||
|
||||
const update = await request(app)
|
||||
.post('/api/stacks/web/update')
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard';
|
||||
const {
|
||||
mockDeployStack,
|
||||
mockRunCommand,
|
||||
mockRunDown,
|
||||
mockUpdateStack,
|
||||
mockDownStack,
|
||||
mockGetContainersByStack,
|
||||
@@ -22,6 +23,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
mockRunDown: vi.fn(),
|
||||
mockUpdateStack: vi.fn(),
|
||||
mockDownStack: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn(),
|
||||
@@ -41,6 +43,7 @@ vi.mock('../services/ComposeService', async () => {
|
||||
getInstance: () => ({
|
||||
deployStack: mockDeployStack,
|
||||
runCommand: mockRunCommand,
|
||||
runDown: mockRunDown,
|
||||
updateStack: mockUpdateStack,
|
||||
downStack: mockDownStack,
|
||||
}),
|
||||
@@ -146,6 +149,7 @@ describe('self stack lifecycle refusal', () => {
|
||||
expect(mockDeployStack).not.toHaveBeenCalled();
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
expect(mockRunDown).not.toHaveBeenCalled();
|
||||
expect(mockDownStack).not.toHaveBeenCalled();
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as policyGate from '../helpers/policyGate';
|
||||
const {
|
||||
mockDeployStack,
|
||||
mockRunCommand,
|
||||
mockRunDown,
|
||||
mockUpdateStack,
|
||||
mockGetContainersByStack,
|
||||
mockRestartContainer,
|
||||
@@ -33,6 +34,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
mockRunDown: vi.fn(),
|
||||
mockUpdateStack: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn(),
|
||||
mockRestartContainer: vi.fn(),
|
||||
@@ -57,6 +59,7 @@ vi.mock('../services/ComposeService', async () => {
|
||||
getInstance: () => ({
|
||||
deployStack: mockDeployStack,
|
||||
runCommand: mockRunCommand,
|
||||
runDown: mockRunDown,
|
||||
updateStack: mockUpdateStack,
|
||||
}),
|
||||
},
|
||||
@@ -140,6 +143,7 @@ afterAll(() => {
|
||||
beforeEach(() => {
|
||||
mockDeployStack.mockReset();
|
||||
mockRunCommand.mockReset();
|
||||
mockRunDown.mockReset();
|
||||
mockUpdateStack.mockReset();
|
||||
mockGetContainersByStack.mockReset();
|
||||
mockRestartContainer.mockReset();
|
||||
@@ -388,8 +392,8 @@ describe('post-deploy scan opt-out', () => {
|
||||
});
|
||||
|
||||
describe('deploy_failure notification on /down error', () => {
|
||||
it('dispatches deploy_failure alert when runCommand (down) throws', async () => {
|
||||
mockRunCommand.mockRejectedValue(new Error('container removal error'));
|
||||
it('dispatches deploy_failure alert when runDown throws', async () => {
|
||||
mockRunDown.mockRejectedValue(new Error('container removal error'));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/myapp/down')
|
||||
|
||||
Reference in New Issue
Block a user