mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user