From bfabdc244447af6ad033a611584484c9b76277fa Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 23:45:53 -0400 Subject: [PATCH] fix(fleet-secrets): invalidate stale preview when target inputs change (#1756) * fix(fleet-secrets): invalidate stale preview when target inputs change Clear the preview plan and push results whenever a target input (selectedLabels, labelMode, stackName, or envFile) changes, so the operator cannot review a diff computed from different inputs. A version counter in a ref drops in-flight preview/push responses when inputs change while a request is still pending. * fix(fleet-secrets): always surface push results, add invalidation test Remove the version guard from handlePush so a completed secret write always reports its outcome to the operator, even if target inputs changed while the push was in flight. The guard remains on handlePreview (read-only). Add a component test covering: plan cleared on input change, stale in-flight preview discarded, and push results always surfaced. --- .../fleet/secrets/SecretPushSheet.test.tsx | 157 ++++++++++++++++++ .../fleet/secrets/SecretPushSheet.tsx | 13 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx diff --git a/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx b/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx new file mode 100644 index 00000000..a04cf998 --- /dev/null +++ b/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx @@ -0,0 +1,157 @@ +/** + * SecretPushSheet: stale-preview invalidation and push-result always surfaced. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { SecretPushPlanEntry, SecretPushResultEntry } from '@/lib/secretsApi'; + +vi.mock('@/lib/secretsApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, previewPush: vi.fn(), executePush: vi.fn() }; +}); + +vi.mock('@/lib/blueprintsApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listDistinctLabels: vi.fn() }; +}); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => ({ nodes: [{ id: 1, name: 'central', type: 'local' }] }), +})); + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({ envFiles: ['.env'] }) }), +})); + +import { previewPush, executePush } from '@/lib/secretsApi'; +import { listDistinctLabels } from '@/lib/blueprintsApi'; +import { toast } from '@/components/ui/toast-store'; +import { SecretPushSheet } from './SecretPushSheet'; + +function planEntry(overrides: Partial = {}): SecretPushPlanEntry { + return { + nodeId: 1, nodeName: 'central', stackName: 'my-app', envFileBasename: '.env', + reachable: true, stackExists: true, + diff: [{ key: 'DB_PASSWORD', status: 'changed', before: '***', after: '***' }], + added: 0, changed: 1, unchanged: 4, removedInformational: 0, + ...overrides, + }; +} + +function resultEntry(overrides: Partial = {}): SecretPushResultEntry { + return { + nodeId: 1, nodeName: 'central', stackName: 'my-app', envFileBasename: '.env', + status: 'ok', added: 0, changed: 1, unchanged: 4, + ...overrides, + }; +} + +const secret = { id: 7, name: 'db-creds', description: '', currentVersion: 2, keyCount: 5, createdAt: 0, createdBy: 'admin', updatedAt: 0 }; + +beforeEach(() => { + vi.mocked(listDistinctLabels).mockResolvedValue(['app', 'web', 'db']); + vi.mocked(previewPush).mockResolvedValue([planEntry()]); + vi.mocked(executePush).mockResolvedValue({ pushId: 'p1', results: [resultEntry()] }); +}); + +function renderSheet() { + return render(); +} + +/** Select a label, fill stack name, click Preview. */ +async function fillAndPreview() { + // Wait for labels to load + await screen.findByText('Target'); + // Open label combobox (first element with role combobox;