mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 05:28:28 +00:00
fix(security): refresh overview when image-update checks toggle (#1827)
Security Overview kept a stale Check again affordance after toggling image-update checks because it ignored the settings event and could reuse a cached overview GET.
This commit is contained in:
@@ -16,7 +16,7 @@ import { useImageScan } from '@/hooks/useImageScan';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { Masthead, type Tone } from './mobile/mobile-ui';
|
||||
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import { SENCHO_SETTINGS_CHANGED, type SecurityTab, type SenchoSettingsChangedDetail } from '@/lib/events';
|
||||
import type { ImageFilterValue } from '@/lib/severityStyles';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole, PostureReasonKind } from '@/types/security';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
@@ -196,8 +196,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
.catch(() => ({ items: [], truncated: false }));
|
||||
try {
|
||||
const [overviewRes, summariesRes] = await Promise.all([
|
||||
apiFetch('/security/overview'),
|
||||
apiFetch('/security/image-summaries'),
|
||||
apiFetch('/security/overview', { cache: 'no-store' }),
|
||||
apiFetch('/security/image-summaries', { cache: 'no-store' }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (overviewRes.ok) {
|
||||
@@ -244,6 +244,20 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
return () => { cancelled = true; };
|
||||
}, [activeNode?.id, reloadToken]);
|
||||
|
||||
// Toggling image-update checks flips overview.updateChecksDisabled, which
|
||||
// gates Check again and the uncertain-row description. Refetch so Overview
|
||||
// does not stay stale until a page reload.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const keys = (e as CustomEvent<SenchoSettingsChangedDetail>).detail?.changedKeys ?? [];
|
||||
if (keys.includes('image_update_checks_enabled')) {
|
||||
setReloadToken((t) => t + 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
}, []);
|
||||
|
||||
// Governance panels (suppressions/acks) are control-governed; probe the local
|
||||
// fleet role so a replica renders them read-only, mirroring Settings.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SecurityView must refetch overview when image-update checks are toggled
|
||||
* so Check again and the uncertain-row disabled-checks description are not
|
||||
* stale until a full page reload.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ can: () => true }),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({
|
||||
activeNode: { id: 1, name: 'local', type: 'local' },
|
||||
hasCapability: () => true,
|
||||
activeNodeMeta: { version: '0.98.0' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/hooks/useImageScan', () => ({
|
||||
useImageScan: () => ({ scanningRef: null, scanImage: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('@/components/security/OverviewTab', () => ({
|
||||
OverviewTab: ({ overview }: { overview: { updateChecksDisabled?: boolean } | null }) => {
|
||||
let label = 'loading';
|
||||
if (overview != null) {
|
||||
label = overview.updateChecksDisabled ? 'disabled' : 'enabled';
|
||||
}
|
||||
return <div data-testid="checks-state">{label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/security/ImagesTab', () => ({ ImagesTab: () => null }));
|
||||
vi.mock('@/components/security/FindingsTab', () => ({ FindingsTab: () => null }));
|
||||
vi.mock('@/components/security/ScanPolicyManager', () => ({ ScanPolicyManager: () => null }));
|
||||
vi.mock('@/components/security/ScannerSetupTab', () => ({ ScannerSetupTab: () => null }));
|
||||
vi.mock('@/components/security/HistoryTab', () => ({ HistoryTab: () => null }));
|
||||
vi.mock('@/components/VulnerabilityScanSheet', () => ({ VulnerabilityScanSheet: () => null }));
|
||||
vi.mock('@/components/settings/SuppressionsPanel', () => ({ SuppressionsPanel: () => null }));
|
||||
vi.mock('@/components/settings/MisconfigAckPanel', () => ({ MisconfigAckPanel: () => null }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import { SecurityView } from '../SecurityView';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function overviewBody(updateChecksDisabled: boolean) {
|
||||
return {
|
||||
scannedImages: 1,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
fixable: 0,
|
||||
secrets: 0,
|
||||
misconfigs: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
lastSuccessfulScanAt: Date.now(),
|
||||
scanner: { available: true, version: '0.50.0', source: 'managed', autoUpdate: true },
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: true, eligibleBlockPolicies: 0 },
|
||||
posture: 'Secure',
|
||||
postureReasons: [],
|
||||
primaryAction: null,
|
||||
updateChecksDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonOk(body: unknown) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => body });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
let overviewCalls = 0;
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/security/overview') {
|
||||
overviewCalls += 1;
|
||||
return jsonOk(overviewBody(overviewCalls > 1));
|
||||
}
|
||||
if (url === '/security/image-summaries') return jsonOk({});
|
||||
if (url === '/security/overview/trend') return jsonOk([]);
|
||||
if (url === '/security/overview/exploit-intel') return jsonOk({ items: [], truncated: false });
|
||||
if (url === '/fleet/role') return jsonOk({ role: 'control' });
|
||||
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecurityView settings refresh', () => {
|
||||
it('refetches overview when SENCHO_SETTINGS_CHANGED includes image_update_checks_enabled', async () => {
|
||||
render(<SecurityView activeTab="overview" onTabChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('checks-state')).toHaveTextContent('enabled'));
|
||||
const overviewCallsBefore = mockedFetch.mock.calls.filter((c) => c[0] === '/security/overview').length;
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: ['image_update_checks_enabled'] },
|
||||
}));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('checks-state')).toHaveTextContent('disabled'));
|
||||
const overviewCalls = mockedFetch.mock.calls.filter((c) => c[0] === '/security/overview');
|
||||
const summaryCalls = mockedFetch.mock.calls.filter((c) => c[0] === '/security/image-summaries');
|
||||
expect(overviewCalls.length).toBeGreaterThan(overviewCallsBefore);
|
||||
for (const call of [...overviewCalls, ...summaryCalls]) {
|
||||
expect(call[1]).toEqual(expect.objectContaining({ cache: 'no-store' }));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not refetch overview for an unrelated settings key', async () => {
|
||||
render(<SecurityView activeTab="overview" onTabChange={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByTestId('checks-state')).toHaveTextContent('enabled'));
|
||||
const before = mockedFetch.mock.calls.filter((c) => c[0] === '/security/overview').length;
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: ['developer_mode'] },
|
||||
}));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('checks-state')).toHaveTextContent('enabled'));
|
||||
expect(mockedFetch.mock.calls.filter((c) => c[0] === '/security/overview').length).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user