fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses (#1392)

* fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses

The destructive "Stop fleet by label" action could run before its blast
radius was known, and a malformed remote response could be rendered as a
successful zero-stack stop. Both are release blockers for the fleet
stop-by-label flow.

Gate the "Stop fleet" button on a resolved blast radius: the live
match-preview resolving to at least one matching stack, or a dry run of the
current label when the preview endpoint is unavailable. Carry the resolved
node and stack list into the confirm modal so the operator confirms against
the concrete targets rather than a label name alone. Editing the label
invalidates a prior dry-run snapshot, so a stale blast radius cannot
re-enable the action.

Validate the remote local-stop 200 body before trusting it. A body that is
not the local-stop contract (missing matched flag, non-array results, or a
malformed result element) now fails that node with a clear error, consistent
with the non-ok and unreachable paths, instead of defaulting matched to true
and results to empty.

* fix: ignore stale fleet stop-by-label preview responses after the label changes

The debounced match-preview callback set the preview state unconditionally,
so a request issued for one label that resolved after the operator switched
to another label would mark the preview ready with the old label's blast
radius. That re-enabled the destructive Stop and showed stale nodes/stacks
in the confirm modal for a label that no longer matched them.

Guard the in-flight request with a per-run cancelled flag flipped in the
effect cleanup, so a response for a label that is no longer current cannot
set the preview. This mirrors the cancellation pattern already used by the
suggestions effect. Add a test that holds a preview request in-flight,
switches the label, then resolves the stale response and asserts Stop stays
disabled and the old targets do not leak.
This commit is contained in:
Anso
2026-06-18 23:29:20 -04:00
committed by GitHub
parent ba09e6f69e
commit 2821e87d3f
6 changed files with 359 additions and 24 deletions
@@ -609,7 +609,37 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
}
});
it('degrades a malformed 200 body (non-array results) to empty instead of forwarding it', async () => {
it('accepts a legitimate empty stop (matched:true, results:[]) without flagging it malformed', async () => {
const remoteId = db.addNode({
name: 'remote-empty', type: 'remote', api_url: 'http://remote-empty.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
const label = db.createLabel(remoteId, `remote-empty-${++labelCounter}`, 'teal');
db.setStackLabels('alpha', remoteId, [label.id]);
// The local-stop receiver emits exactly this when the label exists with no
// assigned stacks. The guard must let it through, not over-reject it.
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, status: 200, json: async () => ({ matched: true, results: [] }),
} as unknown as Response);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(remoteRow.reachable).toBe(true);
expect(remoteRow.matched).toBe(true);
expect(remoteRow.stackResults).toEqual([]);
expect(remoteRow.error).toBeUndefined();
} finally {
db.deleteNode(remoteId);
}
});
it('fails a node whose 200 body has a non-array results, never rendering it as zero-stack success', async () => {
const remoteId = db.addNode({
name: 'remote-malformed', type: 'remote', api_url: 'http://remote-malformed.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
@@ -628,7 +658,41 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
// A malformed contract is a node failure: reachable:false + error, not the
// matched:true/empty-results shape the UI reads as a successful no-op.
expect(remoteRow.reachable).toBe(false);
expect(remoteRow.matched).toBe(false);
expect(remoteRow.stackResults).toEqual([]);
expect(remoteRow.error).toMatch(/malformed/i);
} finally {
db.deleteNode(remoteId);
}
});
it('fails a node whose 200 body omits the matched flag instead of defaulting it to true', async () => {
const remoteId = db.addNode({
name: 'remote-no-matched', type: 'remote', api_url: 'http://remote-no-matched.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
const label = db.createLabel(remoteId, `remote-no-matched-${++labelCounter}`, 'teal');
db.setStackLabels('alpha', remoteId, [label.id]);
// The previous default (`matched: remote.matched ?? true`) turned this body
// into a matched:true/zero-stack success; the guard must fail the node.
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, status: 200, json: async () => ({ results: [] }),
} as unknown as Response);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(remoteRow.reachable).toBe(false);
expect(remoteRow.matched).toBe(false);
expect(remoteRow.error).toMatch(/malformed/i);
} finally {
db.deleteNode(remoteId);
}