Files
sencho/backend/src/__tests__/labels-bulk-actions.test.ts
T
Anso 35bb74425b feat: guide missing external network creation during deploy (#1645)
* feat: guide missing external network creation during deploy

Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance.

* test: align deploy context and settings fixtures with missing-network gate

Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting.

* fix: drop unused renderError binding in missing-network resolver

Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence.

* fix: use HTTP-safe clipboard helper in missing-network dialog

navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances.

* fix: simplify missing-network dialog actions and copy label

Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
2026-07-17 14:51:00 -04:00

242 lines
9.1 KiB
TypeScript

import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let mockFsStacks: string[] = [];
const deployStack = vi.fn();
const getContainersByStack = vi.fn();
const stopContainer = vi.fn();
const restartContainer = vi.fn();
const enforcePolicyPreDeploy = vi.fn();
const invalidateNodeCaches = vi.fn();
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: vi.fn(() => ({
getStacks: vi.fn(async () => mockFsStacks),
})),
},
}));
vi.mock('../services/ComposeService', () => ({
ComposeService: {
getInstance: vi.fn(() => ({ deployStack })),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: vi.fn(() => ({
getContainersByStack,
stopContainer,
restartContainer,
})),
},
}));
vi.mock('../services/PolicyEnforcement', () => ({
enforcePolicyPreDeploy,
}));
vi.mock('../helpers/cacheInvalidation', () => ({
invalidateNodeCaches,
}));
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let db: import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let activeBulkActions: typeof import('../routes/labels').activeBulkActions;
let StackOpLockService: typeof import('../services/StackOpLockService').StackOpLockService;
let labelCounter = 0;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ activeBulkActions } = await import('../routes/labels'));
({ StackOpLockService } = await import('../services/StackOpLockService'));
const { DatabaseService } = await import('../services/DatabaseService');
db = DatabaseService.getInstance();
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
// restoreAllMocks only resets spies; bare vi.fn() mocks keep their call
// history across tests. Clear them all so each test sees a fresh slate
// before its `.not.toHaveBeenCalled()` assertions run.
vi.clearAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
mockFsStacks = ['alpha', 'beta'];
deployStack.mockResolvedValue(undefined);
getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]);
stopContainer.mockResolvedValue(undefined);
restartContainer.mockResolvedValue(undefined);
enforcePolicyPreDeploy.mockResolvedValue({ ok: true });
activeBulkActions.clear();
StackOpLockService.resetForTests();
db.getDb().prepare('DELETE FROM stack_label_assignments').run();
db.getDb().prepare('DELETE FROM stack_labels').run();
});
async function createAssignedLabel(stacks: string[] = ['alpha']) {
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: `bulk-${++labelCounter}`, color: 'teal' });
expect(created.status).toBe(201);
for (const stack of stacks) {
const assigned = await request(app)
.put(`/api/stacks/${stack}/labels`)
.set('Authorization', authHeader)
.send({ labelIds: [created.body.id] });
expect(assigned.status).toBe(200);
}
return created.body as { id: number; node_id: number; name: string; color: string };
}
describe('Stack Labels bulk actions', () => {
it('deploys every existing stack assigned to the label', async () => {
const label = await createAssignedLabel(['alpha']);
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'deploy' });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([{ stackName: 'alpha', success: true }]);
expect(enforcePolicyPreDeploy).toHaveBeenCalledWith('alpha', label.node_id, expect.any(Object));
expect(deployStack).toHaveBeenCalledWith('alpha', undefined, false, {
source: 'labels',
actor: TEST_USERNAME,
});
expect(invalidateNodeCaches).toHaveBeenCalledWith(label.node_id);
});
it('reports partial Docker stop failures without aborting other stacks', async () => {
const label = await createAssignedLabel(['alpha', 'beta']);
getContainersByStack.mockImplementation(async (stackName: string) => {
if (stackName === 'beta') throw new Error('socket permission denied');
return [{ Id: `${stackName}-1` }];
});
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'stop' });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([
{ stackName: 'alpha', success: true },
{ stackName: 'beta', success: false, error: 'socket permission denied' },
]);
expect(stopContainer).toHaveBeenCalledWith('alpha-1');
expect(invalidateNodeCaches).toHaveBeenCalledWith(label.node_id);
});
it('rejects a second bulk action while the node lock is held', async () => {
const label = await createAssignedLabel(['alpha']);
activeBulkActions.add(`bulk:${label.node_id}`);
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'restart' });
expect(res.status).toBe(429);
expect(res.body.error).toContain('already running');
expect(restartContainer).not.toHaveBeenCalled();
});
it('skips a stack whose per-stack lock is held by a manual operation', async () => {
const label = await createAssignedLabel(['alpha', 'beta']);
// A manual operation holds 'alpha'; the bulk deploy must not race it.
StackOpLockService.getInstance().tryAcquire(label.node_id, 'alpha', 'update', 'admin');
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'deploy' });
expect(res.status).toBe(200);
const alpha = res.body.results.find((r: { stackName: string }) => r.stackName === 'alpha');
const beta = res.body.results.find((r: { stackName: string }) => r.stackName === 'beta');
expect(alpha).toMatchObject({ stackName: 'alpha', success: false });
expect(alpha.error).toContain('another operation (update) is already in progress');
expect(beta).toEqual({ stackName: 'beta', success: true });
// 'alpha' was skipped; only 'beta' reached ComposeService.
expect(deployStack).toHaveBeenCalledTimes(1);
expect(deployStack).toHaveBeenCalledWith('beta', undefined, false, {
source: 'labels',
actor: TEST_USERNAME,
});
});
it('dry-run deploy runs the policy gate and reports blocked stacks honestly', async () => {
const label = await createAssignedLabel(['alpha']);
enforcePolicyPreDeploy.mockResolvedValue({
ok: false,
policy: { name: 'block-criticals', max_severity: 'high' },
violations: [{ imageRef: 'nginx:latest', severity: 'CRITICAL', reasons: ['severity'] }],
});
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'deploy', dryRun: true });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([
expect.objectContaining({ stackName: 'alpha', success: false, dryRun: true }),
]);
expect(res.body.results[0].error).toContain('Policy "block-criticals" blocked deploy');
expect(res.body.results[0].error).toContain('matched severity threshold');
expect(deployStack).not.toHaveBeenCalled();
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
it('dry-run deploy reports success when the policy gate passes, without touching Docker', async () => {
const label = await createAssignedLabel(['alpha']);
enforcePolicyPreDeploy.mockResolvedValue({ ok: true });
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'deploy', dryRun: true });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([
{ stackName: 'alpha', success: true, dryRun: true },
]);
expect(enforcePolicyPreDeploy).toHaveBeenCalledWith('alpha', label.node_id, expect.any(Object));
expect(deployStack).not.toHaveBeenCalled();
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
it('dry-run stop reports per-stack success without dispatching real stops', async () => {
const label = await createAssignedLabel(['alpha', 'beta']);
const res = await request(app)
.post(`/api/labels/${label.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'stop', dryRun: true });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([
{ stackName: 'alpha', success: true, dryRun: true },
{ stackName: 'beta', success: true, dryRun: true },
]);
expect(getContainersByStack).not.toHaveBeenCalled();
expect(stopContainer).not.toHaveBeenCalled();
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
});