feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)

* feat(fleet): cross-node bulk label assign with authoritative label discovery

Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack
label that exists anywhere in the fleet, select stacks on one or more nodes,
and the control orchestrates: each target node resolves the label by name,
creating it with the same name and color if missing, then adds it to the
selected stacks while preserving their existing labels. The local node runs
in process; each remote runs its own admin-only local-assign receiver over
the node proxy. Per-node failures (unknown node, no proxy target, unreachable,
mixed-version remote) degrade that node only and are reported per node in the
result. Assignment writes use a transactional INSERT OR IGNORE so the
add-preserve path is idempotent and race-free.

Also make the shared fleet label discovery authoritative: suggestions,
match-preview, and the fleet-stop remote leg now read each node's labels live
over the proxy instead of the control database, which does not mirror remote
labels. A propagated label therefore appears in, and is stoppable by,
Stop-by-label across the fleet, and unreachable nodes are surfaced rather than
silently dropped.

Fleet Actions runs against the unfiltered node list, so overview filters no
longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign
endpoint is removed.

* fix(fleet): treat malformed remote label responses as per-node failures

A 200 response from a remote node whose body is not the expected shape was
treated as a benign empty result, so a malformed remote could read as a clean
zero-stack assign or a "matched, nothing to stop" no-op and even surface a
success toast. Validate the wire shape in the bulk-assign and fleet-stop remote
legs and in the authoritative label discovery fan-out; on a malformed body,
report the node as a per-node failure with the error attributed to its stacks
instead of silently dropping it.

* chore: drop accidentally committed temp file
This commit is contained in:
Anso
2026-06-20 11:52:28 -04:00
committed by GitHub
parent cd9247db1e
commit d26ab58189
13 changed files with 1209 additions and 338 deletions
+426 -36
View File
@@ -1,6 +1,9 @@
/**
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
* validation, and orchestration shape across the two routes.
* validation, and cross-node orchestration shape across the fleet label routes:
* the authoritative discovery reads (suggestions / match-preview / fleet-stop),
* the bulk-assign orchestrator, and the per-node local-stop / local-assign
* receivers.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
@@ -28,9 +31,20 @@ function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}
function makeStack(name: string): void {
const composeDir = process.env.COMPOSE_DIR as string;
fs.mkdirSync(path.join(composeDir, name), { recursive: true });
fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n');
}
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: [] });
it('POST /api/fleet/labels/bulk-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/labels/bulk-assign').send({ label: { name: 'x', color: 'teal' }, targets: [] });
expect(res.status).toBe(401);
});
it('POST /api/fleet-actions/labels/local-assign returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet-actions/labels/local-assign').send({ label: { name: 'x', color: 'teal' }, stackNames: [] });
expect(res.status).toBe(401);
});
@@ -54,15 +68,26 @@ describe('Fleet Actions tier gating (Community + admin)', () => {
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', async () => {
it('POST /api/fleet/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')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
.send({ label: { name: 'community-ok', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['nope'] }] });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.results).toEqual([]);
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/local-assign is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'community-recv', color: 'teal' }, stackNames: [] });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body).toEqual({ created: expect.any(Boolean), results: [] });
});
});
@@ -86,21 +111,76 @@ describe('Fleet Actions input validation', () => {
expect(res.status).toBe(400);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
it('POST /api/fleet/labels/bulk-assign rejects an invalid label color', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: 'oops' });
.send({ label: { name: 'media', color: 'not-a-color' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/assignments must be an array/);
expect(res.body.error).toMatch(/color/);
});
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
it('POST /api/fleet/labels/bulk-assign rejects a missing label name', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: big });
.send({ label: { color: 'teal' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/label\.name/);
});
it('POST /api/fleet/labels/bulk-assign rejects empty targets', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/targets/);
});
it('POST /api/fleet/labels/bulk-assign rejects a non-integer nodeId', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 'abc', stackNames: ['x'] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/nodeId/);
});
it('POST /api/fleet/labels/bulk-assign rejects when all target groups are empty', async () => {
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: [] }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no target stacks/);
});
it('POST /api/fleet/labels/bulk-assign rejects an oversized total', async () => {
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: big }] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/may not exceed/);
});
it('POST /api/fleet-actions/labels/local-assign rejects non-array stackNames', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, stackNames: 'oops' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/stackNames must be an array/);
});
it('POST /api/fleet-actions/labels/local-assign rejects an oversized payload', async () => {
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, stackNames: big });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/may not exceed/);
});
@@ -122,22 +202,20 @@ describe('Fleet Actions orchestration shape', () => {
}
});
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
it('POST /api/fleet/labels/bulk-assign reports an unknown node per-node without failing the request', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['a', 'b'] }] });
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' });
expect(res.body.results).toHaveLength(1);
const row = res.body.results[0];
expect(row.reachable).toBe(false);
expect(row.error).toBe('Unknown node');
expect(row.stackResults).toEqual([
{ stackName: 'a', success: false, error: 'Unknown node' },
{ stackName: 'b', success: false, error: 'Unknown node' },
]);
});
});
@@ -196,7 +274,7 @@ describe('local-stop behavior', () => {
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');
db.createLabel(nodeId, 'no-stacks-label', 'slate');
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
@@ -206,7 +284,7 @@ describe('local-stop behavior', () => {
});
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');
const label = db.createLabel(nodeId, 'busy-label', 'slate');
db.setStackLabels('busy-stack', nodeId, [label.id]);
const { activeBulkActions } = await import('../routes/labels');
activeBulkActions.add(`bulk:${nodeId}`);
@@ -226,10 +304,8 @@ describe('local-stop behavior', () => {
});
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');
makeStack('dry-stack');
const label = db.createLabel(nodeId, 'dry-label', 'slate');
db.setStackLabels('dry-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
@@ -241,7 +317,7 @@ describe('local-stop behavior', () => {
});
it('filters out assigned stacks that are not present on disk', async () => {
const label = db.createLabel(nodeId, 'ghost-label', '#ffffff');
const label = db.createLabel(nodeId, 'ghost-label', 'slate');
db.setStackLabels('ghost-stack', nodeId, [label.id]);
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
@@ -252,6 +328,320 @@ describe('local-stop behavior', () => {
});
});
describe('local-assign receiver 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('creates the label when missing and assigns it to the stack', async () => {
makeStack('recv-create');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-created', color: 'blue' }, stackNames: ['recv-create'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(true);
expect(res.body.results).toEqual([{ stackName: 'recv-create', success: true }]);
const created = db.getLabels(nodeId).find(l => l.name === 'recv-created');
expect(created).toBeTruthy();
expect(db.getLabelsForStacks(nodeId)['recv-create'].map(l => l.name)).toContain('recv-created');
});
it('reuses an existing label (created:false) and preserves existing assignments', async () => {
makeStack('recv-reuse');
const existing = db.createLabel(nodeId, 'recv-existing', 'green');
db.setStackLabels('recv-reuse', nodeId, [existing.id]);
db.createLabel(nodeId, 'recv-reused', 'purple');
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-reused', color: 'purple' }, stackNames: ['recv-reuse'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
const names = db.getLabelsForStacks(nodeId)['recv-reuse'].map(l => l.name).sort();
expect(names).toEqual(['recv-existing', 'recv-reused']);
});
it('reports a per-stack error for a stack that is not on disk', async () => {
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-ghost', color: 'rose' }, stackNames: ['recv-not-on-disk'] });
expect(res.status).toBe(200);
expect(res.body.results).toEqual([{ stackName: 'recv-not-on-disk', success: false, error: 'Stack not found' }]);
});
it('fails all stacks with the cap message when the node is at the label limit', async () => {
makeStack('recv-cap');
vi.spyOn(db, 'getLabelCount').mockReturnValue(50);
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-over-cap', color: 'teal' }, stackNames: ['recv-cap'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
expect(res.body.results[0]).toMatchObject({ stackName: 'recv-cap', success: false });
expect(res.body.results[0].error).toMatch(/Maximum of 50/);
});
it('reuses an existing label after a concurrent-create unique violation', async () => {
makeStack('recv-race');
// First read sees no label (so a create is attempted); create throws a unique
// violation as if another request won the race; the re-fetch then finds it.
const raced = { id: 9991, node_id: nodeId, name: 'recv-raced', color: 'teal' as const };
vi.spyOn(db, 'getLabels').mockReturnValueOnce([]).mockReturnValue([raced]);
const uniqueErr = Object.assign(new Error('UNIQUE constraint failed'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
vi.spyOn(db, 'createLabel').mockImplementation(() => { throw uniqueErr; });
const addSpy = vi.spyOn(db, 'addStackLabels').mockImplementation(() => {});
const res = await request(app)
.post('/api/fleet-actions/labels/local-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'recv-raced', color: 'teal' }, stackNames: ['recv-race'] });
expect(res.status).toBe(200);
expect(res.body.created).toBe(false);
expect(res.body.results).toEqual([{ stackName: 'recv-race', success: true }]);
expect(addSpy).toHaveBeenCalledWith('recv-race', nodeId, [9991]);
});
});
describe('DatabaseService.addStackLabels', () => {
let db: import('../services/DatabaseService').DatabaseService;
let nodeId: number;
beforeAll(async () => {
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
nodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
});
it('adds labels while preserving existing assignments', () => {
const a = db.createLabel(nodeId, 'add-pre-a', 'teal');
const b = db.createLabel(nodeId, 'add-pre-b', 'blue');
db.setStackLabels('add-pre-stack', nodeId, [a.id]);
db.addStackLabels('add-pre-stack', nodeId, [b.id]);
const names = db.getLabelsForStacks(nodeId)['add-pre-stack'].map(l => l.name).sort();
expect(names).toEqual(['add-pre-a', 'add-pre-b']);
});
it('is idempotent on re-add (no duplicate row, no throw)', () => {
const a = db.createLabel(nodeId, 'idem-a', 'teal');
db.addStackLabels('idem-stack', nodeId, [a.id]);
db.addStackLabels('idem-stack', nodeId, [a.id]);
expect(db.getLabelsForStacks(nodeId)['idem-stack'].filter(l => l.name === 'idem-a')).toHaveLength(1);
});
it('throws when a label id does not belong to the node', () => {
expect(() => db.addStackLabels('bad-id-stack', nodeId, [999999])).toThrow(/invalid for this node/);
});
it('no-ops on an empty id list', () => {
expect(() => db.addStackLabels('empty-id-stack', nodeId, [])).not.toThrow();
expect(db.getLabelsForStacks(nodeId)['empty-id-stack']).toBeUndefined();
});
});
describe('bulk-assign orchestrator: local node', () => {
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('creates the label on the local node and assigns it, preserving existing labels', async () => {
makeStack('orch-local');
const existing = db.createLabel(nodeId, 'orch-existing', 'amber');
db.setStackLabels('orch-local', nodeId, [existing.id]);
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-media', color: 'teal' }, targets: [{ nodeId, stackNames: ['orch-local'] }] });
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
const row = res.body.results[0];
expect(row.reachable).toBe(true);
expect(row.created).toBe(true);
expect(row.stackResults).toEqual([{ stackName: 'orch-local', success: true }]);
const names = db.getLabelsForStacks(nodeId)['orch-local'].map(l => l.name).sort();
expect(names).toEqual(['orch-existing', 'orch-media']);
});
it('reuses an existing local label (created:false)', async () => {
makeStack('orch-reuse');
db.createLabel(nodeId, 'orch-reused', 'cyan');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-reused', color: 'cyan' }, targets: [{ nodeId, stackNames: ['orch-reuse'] }] });
expect(res.status).toBe(200);
expect(res.body.results[0].created).toBe(false);
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-reuse', success: true }]);
});
it('dedupes repeated stack names within a target', async () => {
makeStack('orch-dedupe');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'orch-dd', color: 'pink' }, targets: [{ nodeId, stackNames: ['orch-dedupe', 'orch-dedupe'] }] });
expect(res.status).toBe(200);
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-dedupe', success: true }]);
});
});
describe('bulk-assign orchestrator: remote fan-out', () => {
let db: import('../services/DatabaseService').DatabaseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
beforeAll(async () => {
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
({ NodeRegistry } = await import('../services/NodeRegistry'));
});
afterEach(() => {
vi.restoreAllMocks();
for (const n of db.getNodes().filter(n => n.type === 'remote')) db.deleteNode(n.id);
});
function addRemote(name: string, mode: 'proxy' | 'pilot_agent' = 'proxy'): number {
return db.addNode({
name, type: 'remote', mode,
compose_dir: '/tmp', is_default: false,
api_url: mode === 'proxy' ? 'https://remote.example.com:1852' : '',
api_token: mode === 'proxy' ? 'remote-tok' : '',
});
}
it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
const remoteId = addRemote('assign-remote-ok');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(true);
expect(row.created).toBe(true);
expect(row.stackResults).toEqual([{ stackName: 'r1', success: true }]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
expect(String(call[0])).toBe('https://remote.example.com:1852/api/fleet-actions/labels/local-assign');
const init = call[1] as { method: string; headers: Record<string, string>; body: string };
expect(init.method).toBe('POST');
expect(init.headers['Authorization']).toBe('Bearer remote-tok');
expect(JSON.parse(init.body)).toEqual({ label: { name: 'media', color: 'teal' }, stackNames: ['r1'] });
});
it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['p1'] }] });
expect(res.status).toBe(200);
const init = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).not.toHaveProperty('Authorization');
});
it('reports no-proxy-target as a per-node failure without blocking the request', async () => {
const remoteId = addRemote('assign-remote-down', 'pilot_agent');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toBeTruthy();
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: row.error }]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-404');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toMatch(/404/);
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
});
it('reports a transport failure as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-transport');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
});
it('reports a malformed 200 body as a per-node failure', async () => {
const remoteId = addRemote('assign-remote-malformed');
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ created: true, results: 'not-an-array' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const res = await request(app)
.post('/api/fleet/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
expect(res.status).toBe(200);
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(row.reachable).toBe(false);
expect(row.error).toMatch(/malformed/);
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
});
});
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;
@@ -266,7 +656,7 @@ describe('fleet-stop degrades the local leg per-node instead of failing the whol
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');
const label = db.createLabel(nodeId, 'degrade-label', 'slate');
db.setStackLabels('degrade-stack', nodeId, [label.id]);
const { FileSystemService } = await import('../services/FileSystemService');
vi.spyOn(FileSystemService.prototype, 'getStacks').mockRejectedValue(new Error('compose dir unreadable'));