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.
This commit is contained in:
Anso
2026-08-02 23:45:53 -04:00
committed by GitHub
parent 2ad1212bbb
commit bfabdc2444
2 changed files with 169 additions and 1 deletions
@@ -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<typeof import('@/lib/secretsApi')>();
return { ...actual, previewPush: vi.fn(), executePush: vi.fn() };
});
vi.mock('@/lib/blueprintsApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/blueprintsApi')>();
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> = {}): 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> = {}): 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(<SecretPushSheet open onOpenChange={vi.fn()} secret={secret} />);
}
/** 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; <select> is second)
const combos = screen.getAllByRole('combobox');
await userEvent.click(combos[0]);
// Click a label option
const option = await screen.findByText('app');
await userEvent.click(option);
// Fill stack name
const stackInput = screen.getByPlaceholderText('my-app');
await userEvent.clear(stackInput);
await userEvent.type(stackInput, 'my-app');
// Click Preview
await userEvent.click(screen.getByRole('button', { name: /^preview$/i }));
}
describe('SecretPushSheet', () => {
it('clears the plan when the stack name changes after preview', async () => {
renderSheet();
await fillAndPreview();
// Plan entry visible on Preview tab
await waitFor(() => {
expect(screen.getByText('central')).toBeInTheDocument();
});
// Switch to Target tab and change stack name
await userEvent.click(screen.getByRole('tab', { name: /target/i }));
await userEvent.type(screen.getByDisplayValue('my-app'), '-v2');
// Preview tab click should be blocked (plan cleared)
await userEvent.click(screen.getByRole('tab', { name: /preview/i }));
expect(screen.getByPlaceholderText('my-app')).toBeInTheDocument();
expect(screen.queryByText('central')).not.toBeInTheDocument();
});
it('discards a stale in-flight preview response when inputs change', async () => {
let resolvePreview!: (value: SecretPushPlanEntry[]) => void;
vi.mocked(previewPush).mockReturnValue(new Promise((r) => { resolvePreview = r; }));
renderSheet();
await screen.findByText('Target');
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByText('app'));
const stackInput = screen.getByPlaceholderText('my-app');
await userEvent.clear(stackInput);
await userEvent.type(stackInput, 'my-app');
await userEvent.click(screen.getByRole('button', { name: /^preview$/i }));
// Switch to Target while preview is in flight, change an input
await userEvent.click(screen.getByRole('tab', { name: /target/i }));
await userEvent.type(screen.getByDisplayValue('my-app'), '-changed');
// Resolve stale preview
resolvePreview([planEntry({ nodeName: 'stale-result' })]);
await waitFor(() => {
expect(screen.queryByText('stale-result')).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText('my-app')).toBeInTheDocument();
});
it('always surfaces push results even when inputs change mid-flight', async () => {
// Real preview so Push button appears
vi.mocked(previewPush).mockResolvedValue([planEntry()]);
let resolvePush!: (v: { pushId: string; results: SecretPushResultEntry[] }) => void;
vi.mocked(executePush).mockReturnValue(new Promise((r) => { resolvePush = r; }));
renderSheet();
await fillAndPreview();
await waitFor(() => {
expect(screen.getByText('central')).toBeInTheDocument();
});
// Click Push
await userEvent.click(screen.getByRole('button', { name: /push to 1 node/i }));
// Switch to Target while push is in flight, change an input
await userEvent.click(screen.getByRole('tab', { name: /target/i }));
await userEvent.type(screen.getByDisplayValue('my-app'), '-changed');
// Resolve push. Must always surface (C1 fix).
resolvePush({ pushId: 'p1', results: [resultEntry({ nodeName: 'push-result' })] });
await waitFor(() => {
expect(screen.getByText('push-result')).toBeInTheDocument();
});
expect(vi.mocked(toast.success)).toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader2, Send, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, MinusCircle, type LucideIcon } from 'lucide-react';
import { SystemSheet, SheetSection, type SystemSheetTab } from '@/components/ui/system-sheet';
import { Input } from '@/components/ui/input';
@@ -59,6 +59,7 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) {
const [plan, setPlan] = useState<SecretPushPlanEntry[]>([]);
const [results, setResults] = useState<SecretPushResultEntry[]>([]);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const inputVersionRef = useRef(0);
useEffect(() => {
if (!open) return;
@@ -75,6 +76,14 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) {
.catch(() => setAllLabels([]));
}, [open]);
// Invalidate preview/results computed from the old inputs: clear them and
// bump a version counter so an in-flight request for the old inputs is dropped.
useEffect(() => {
setPlan([]);
setResults([]);
inputVersionRef.current += 1;
}, [selectedLabels, labelMode, stackName, envFile]);
const labelOptions: MultiSelectOption[] = useMemo(
() => allLabels.map((l) => ({ value: l, label: l })),
[allLabels],
@@ -134,12 +143,14 @@ export function SecretPushSheet({ open, onOpenChange, secret }: Props) {
return;
}
setPreviewLoading(true);
const version = inputVersionRef.current;
try {
const result = await previewPush(secret.id, {
selector: buildSelector(),
stackName: stackName.trim(),
envFileBasename: envFile,
});
if (inputVersionRef.current !== version) return;
if (result.length === 0) {
toast.error('No nodes match this selector');
return;