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