Files
sencho/backend/src/__tests__/fleet-actions.test.ts
T
Anso 7e0cffa376 fix(fleet-actions): stop-by-label works on Community remote nodes (#1270)
* fix(fleet-actions): stop-by-label works on Community remote nodes

Fleet-stop's remote leg fanned out to POST /api/labels/:id/action, which
is gated to Skipper/Admiral, so on a Community fleet the control node
stopped its own stacks but every remote node returned 403. Fleet-stop
itself is admin-only and available on every license, so the remote leg
contradicted the feature's own gate.

Extract the label-match plus bulk-stop logic into a shared
runLocalLabelStop helper and add an admin-only, every-license
POST /api/fleet-actions/labels/local-stop receiver. The control now fans
out to that receiver, so remote stacks stop on every tier. Each node runs
under its own per-node bulk lock, so a fleet-stop and a per-label action
still serialize cleanly instead of double-stopping containers.

Also degrade the control's own leg per-node instead of failing the whole
fan-out when its filesystem read throws, and gate fleet-stop and
fleet-prune diagnostics behind developer_mode.

Tests: local-stop auth, tier, validation, and behavior; a remote-leg
routing guard that asserts the fan-out targets local-stop and never the
paid route; local-leg graceful degradation; and the three Fleet Action
card UIs.

* fix(fleet-actions): honor the remote stop receiver's matched flag

The control reached the remote leg only because its own mirror had the
label, then hardcoded matched:true and trusted results without guarding
its shape. A mirror-skewed control (mirror has the label, remote does
not) then showed a remote mismatch as "matched, 0 stacks" instead of
"no matching label", and a malformed 200 body could flow a non-array
into the per-stack renderers.

Honor the remote's own matched flag and coerce results to an array when
the body is malformed. Add regression tests for the matched:false skew
case and the non-array results case.
2026-06-01 14:18:44 -04:00

287 lines
11 KiB
TypeScript

/**
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
* validation, and orchestration shape across the two routes.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
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 authHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => cleanupTestDb(tmpDir));
function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}
describe('Fleet Actions endpoints require authentication', () => {
it('POST /api/fleet-actions/labels/bulk-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet-actions/labels/bulk-assign').send({ assignments: [] });
expect(res.status).toBe(401);
});
it('POST /api/fleet/labels/fleet-stop returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/labels/fleet-stop').send({ labelName: 'prod' });
expect(res.status).toBe(401);
});
});
describe('Fleet Actions tier gating (Community + admin)', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: 'this-label-does-not-exist' });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.results).toEqual([]);
});
});
describe('Fleet Actions input validation', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop rejects missing labelName', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/labelName/);
});
it('POST /api/fleet/labels/fleet-stop rejects whitespace-only labelName', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: ' ' });
expect(res.status).toBe(400);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: 'oops' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/assignments must be an array/);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: big });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/may not exceed/);
});
});
describe('Fleet Actions orchestration shape', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop with unknown label returns matched:false per node', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: 'this-label-does-not-exist' });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
for (const row of res.body.results) {
expect(row.matched).toBe(false);
expect(row.stackResults).toEqual([]);
}
});
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([]);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [{ stackName: 'has spaces!', labelIds: [1] }] });
expect(res.status).toBe(200);
expect(res.body.results[0]).toMatchObject({ success: false, error: 'Invalid stack name' });
});
});
// The per-node local-stop receiver is what a control instance calls on each
// remote during a fleet-wide stop. It must be reachable on every license (only
// admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action
// and 403'd on Community remotes. These tests lock that behavior in.
describe('local-stop receiver auth + tier', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet-actions/labels/local-stop returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet-actions/labels/local-stop').send({ labelName: 'prod' });
expect(res.status).toBe(401);
});
it('is reachable on community tier for admins and never returns PAID_REQUIRED', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'this-label-does-not-exist' });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body).toEqual({ matched: false, results: [] });
});
it('rejects missing labelName', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/labelName/);
});
it('rejects whitespace-only labelName', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: ' ' });
expect(res.status).toBe(400);
});
});
describe('local-stop behavior', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { NodeRegistry } = await import('../services/NodeRegistry');
db = DatabaseService.getInstance();
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
});
afterEach(() => vi.restoreAllMocks());
it('matched:true with empty results when the label exists but has no stacks', async () => {
db.createLabel(nodeId, 'no-stacks-label', '#ffffff');
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'no-stacks-label' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ matched: true, results: [] });
});
it('reports per-stack lock contention when a bulk action is already running on the node', async () => {
const label = db.createLabel(nodeId, 'busy-label', '#ffffff');
db.setStackLabels('busy-stack', nodeId, [label.id]);
const { activeBulkActions } = await import('../routes/labels');
activeBulkActions.add(`bulk:${nodeId}`);
try {
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'busy-label' });
expect(res.status).toBe(200);
expect(res.body.matched).toBe(true);
expect(res.body.results).toEqual([
{ stackName: 'busy-stack', success: false, error: 'A bulk action is already running on this node' },
]);
} finally {
activeBulkActions.delete(`bulk:${nodeId}`);
}
});
it('dry run returns dryRun:true per on-disk stack without touching Docker', async () => {
const composeDir = process.env.COMPOSE_DIR as string;
fs.mkdirSync(path.join(composeDir, 'dry-stack'), { recursive: true });
fs.writeFileSync(path.join(composeDir, 'dry-stack', 'docker-compose.yml'), 'services: {}\n');
const label = db.createLabel(nodeId, 'dry-label', '#ffffff');
db.setStackLabels('dry-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'dry-label', dryRun: true });
expect(res.status).toBe(200);
expect(res.body.matched).toBe(true);
expect(res.body.results).toEqual([{ stackName: 'dry-stack', success: true, dryRun: true }]);
});
it('filters out assigned stacks that are not present on disk', async () => {
const label = db.createLabel(nodeId, 'ghost-label', '#ffffff');
db.setStackLabels('ghost-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'ghost-label' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ matched: true, results: [] });
});
});
describe('fleet-stop degrades the local leg per-node instead of failing the whole fan-out', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const { NodeRegistry } = await import('../services/NodeRegistry');
db = DatabaseService.getInstance();
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
});
afterEach(() => vi.restoreAllMocks());
it('returns 200 with per-stack errors when the control filesystem read throws', async () => {
const label = db.createLabel(nodeId, 'degrade-label', '#ffffff');
db.setStackLabels('degrade-stack', nodeId, [label.id]);
const { FileSystemService } = await import('../services/FileSystemService');
vi.spyOn(FileSystemService.prototype, 'getStacks').mockRejectedValue(new Error('compose dir unreadable'));
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: 'degrade-label' });
expect(res.status).toBe(200);
const localRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === nodeId);
expect(localRow.matched).toBe(true);
expect(localRow.stackResults).toEqual([
{ stackName: 'degrade-stack', success: false, error: 'compose dir unreadable' },
]);
});
});