/** * Coverage for ResourcesView hardening. * * Locks correctness fixes that manual smoke testing cannot reliably catch: * - M-1: a slow resource fetch for a previously-active node must not overwrite * the newly-selected node's data (node-switch generation guard). * - M-2: a failed prune must surface the server error, never a false success. * - Reclaim banner: "Review & prune" reclaims every advertised category and a * partial failure reports a warning, never a false success; dismiss snoozes * the banner until the reclaimable total grows past the dismissed snapshot. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(() => 'toast-id'), dismiss: vi.fn(), }, })); const licenseState = { isPaid: true }; vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState })); vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) })); const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } }; vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState })); vi.mock('@/hooks/useTrivyStatus', () => ({ useTrivyStatus: () => ({ status: { available: false, version: null, source: 'none', autoUpdate: false, busy: false }, updateCheck: null, refresh: vi.fn(), refreshUpdateCheck: vi.fn(), }), })); // Heavy or portal-bound children are not under test; stub them to keep the // render tree light and deterministic. vi.mock('../VulnerabilityScanSheet', () => ({ VulnerabilityScanSheet: () => null })); // Testable stub: surfaces visibility (testid) and the two callbacks so the // snooze and reclaim-all flows can be driven without the real banner styling. // Mirrors the real component's bytes<=0 guard. vi.mock('../resources/ReclaimHero', () => ({ ReclaimHero: ({ bytes, onReview, onDismiss }: { bytes: number; onReview: () => void; onDismiss: () => void }) => bytes <= 0 ? null : (
), })); vi.mock('../resources/FootprintTreemap', () => ({ FootprintTreemap: () => null })); vi.mock('../resources/ImageDetailsSheet', () => ({ ImageDetailsSheet: () => null })); vi.mock('../resources/VolumeBrowserSheet', () => ({ VolumeBrowserSheet: () => null })); vi.mock('../NetworkTopologyView', () => ({ default: () => null })); vi.mock('../CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children} })); vi.mock('../LazyBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children} })); vi.mock('../NodeManager', () => ({ SENCHO_NAVIGATE_EVENT: 'sencho-navigate' })); import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import ResourcesView from '../ResourcesView'; const mockedFetch = apiFetch as unknown as ReturnType; function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { return { ok: init.ok ?? true, status: init.status ?? 200, json: async () => body, } as unknown as Response; } function image(repoTag: string) { return { Id: `sha256:${repoTag}`, RepoTags: [repoTag], Size: 1000, Containers: 0, usedByStacks: [], managedBy: null, managedStatus: 'unmanaged' as const, isSencho: false, }; } beforeEach(() => { mockedFetch.mockReset(); licenseState.isPaid = true; nodesState.activeNode = { id: 1 }; localStorage.clear(); }); // Reclaimable usage shape with a non-zero total so the banner is shown. function reclaimableUsage(images: number, volumes: number) { return { reclaimableImages: images, reclaimableContainers: 0, reclaimableVolumes: volumes, reclaimableImageCount: images > 0 ? 1 : 0, reclaimableContainerCount: 0, reclaimableVolumeCount: volumes > 0 ? 1 : 0, managedImageBytes: 0, unmanagedImageBytes: 0, managedVolumeBytes: 0, unmanagedVolumeBytes: 0, }; } function samplePrunePlan(overrides: Record = {}) { return { scope: 'managed', targets: ['images'], items: [{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000, managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] }, }], reclaimableBytes: 1000, fingerprint: 'fp-test', createdAt: Date.now(), nodeId: 1, ...overrides, }; } function reclaimPlan() { return samplePrunePlan({ scope: 'all', targets: ['volumes', 'containers', 'images'], items: [ { target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500, managed: true, reason: 'Volume is not referenced by any container', volume: {}, }, { target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000, managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] }, }, ], reclaimableBytes: 1500, fingerprint: 'fp-reclaim', }); } afterEach(() => vi.clearAllMocks()); describe('ResourcesView', () => { it('drops a stale node fetch so it cannot overwrite the newly selected node (M-1)', async () => { // Hold every /system/resources response open so we control resolution order. const resourcesResolvers: Array<(r: Response) => void> = []; mockedFetch.mockImplementation((url: string) => { if (url === '/system/resources') { return new Promise((resolve) => resourcesResolvers.push(resolve)); } return Promise.resolve(jsonResponse({})); }); const { rerender } = render(); await waitFor(() => expect(resourcesResolvers).toHaveLength(1)); // Switch nodes before the first fetch resolves; the effect re-runs and // claims a newer generation. nodesState.activeNode = { id: 2 }; rerender(); await waitFor(() => expect(resourcesResolvers).toHaveLength(2)); // Resolve the newer (node 2) fetch first; its data should render. resourcesResolvers[1](jsonResponse({ images: [image('node2-img:latest')], volumes: [], networks: [] })); expect(await screen.findByText('node2-img:latest')).toBeInTheDocument(); // Now resolve the stale (node 1) fetch. Its generation is old, so the guard // must drop it rather than stomp node 2's resources. resourcesResolvers[0](jsonResponse({ images: [image('node1-img:latest')], volumes: [], networks: [] })); await waitFor(() => { expect(screen.getByText('node2-img:latest')).toBeInTheDocument(); }); expect(screen.queryByText('node1-img:latest')).not.toBeInTheDocument(); }); it('does not surface a load failure that resolves after the view unmounts', async () => { let rejectResources: ((e: unknown) => void) | undefined; mockedFetch.mockImplementation((url: string) => { if (url === '/system/resources') { return new Promise((_resolve, reject) => { rejectResources = reject; }); } return Promise.resolve(jsonResponse({})); }); const { unmount } = render(); await waitFor(() => expect(rejectResources).toBeDefined()); unmount(); rejectResources!(new Error('network down')); await Promise.resolve(); await Promise.resolve(); expect(toast.error).not.toHaveBeenCalled(); }); it('fetches a prune plan before enabling confirm, then sends the fingerprint', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(samplePrunePlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ success: true, reclaimedBytes: 1000, outcomes: [] })); } if (url === '/system/resources') { return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); } return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources')); await user.click(screen.getByRole('button', { name: /Prune Unused Images/ })); await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith( '/system/prune/plan', expect.objectContaining({ method: 'POST' }), )); expect(await screen.findByText(/old:v1/)).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: /Prune/ })); await waitFor(() => expect(toast.success).toHaveBeenCalled()); const pruneCall = mockedFetch.mock.calls.find( ([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST', ); expect(pruneCall).toBeTruthy(); const body = JSON.parse(String((pruneCall![1] as RequestInit).body)); expect(body.planFingerprint).toBe('fp-test'); }); it('discloses extra repository tags in the prune confirm list', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(samplePrunePlan({ items: [{ target: 'images', id: 'img-multi', name: 'ghcr.io/example/very-long-app-name:1.5.2-build.1844', sizeBytes: 1000, managed: false, reason: 'Image is not used by any container', image: { references: [ 'ghcr.io/example/very-long-app-name:1.5.2-build.1844', 'ghcr.io/example/very-long-app-name:latest', ], }, }], }))); } if (url === '/system/resources') { return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); } return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources')); await user.click(screen.getByRole('button', { name: /Prune Unused Images/ })); const extraRef = await screen.findByText('ghcr.io/example/very-long-app-name:latest'); expect(extraRef).toHaveClass('break-all'); expect(extraRef).not.toHaveClass('truncate'); expect(screen.getByText(/1\.5\.2-build\.1844/)).toBeInTheDocument(); }); it('surfaces the server error on a failed prune instead of a false success (M-2)', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(samplePrunePlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ error: 'Prune blew up' }, { ok: false, status: 500 })); } if (url === '/system/resources') { return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); } return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources')); await user.click(screen.getByRole('button', { name: /Prune Unused Images/ })); await waitFor(() => expect(screen.getByText(/old:v1/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /Prune/ })); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Prune blew up')); expect(toast.success).not.toHaveBeenCalled(); }); it('reports partial failure from "Review & prune" without a false success', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(reclaimPlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(toast.success).not.toHaveBeenCalled(); const pruned = mockedFetch.mock.calls .filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST'); expect(pruned).toHaveLength(1); const body = JSON.parse(String((pruned[0][1] as RequestInit).body)); expect(body.targets).toEqual(['volumes', 'containers', 'images']); expect(body.planFingerprint).toBe('fp-reclaim'); }); it('reports an error when every prune fails, with no success or warning', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(reclaimPlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ error: 'daemon down' }, { ok: false, status: 500 })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('daemon down')); expect(toast.success).not.toHaveBeenCalled(); expect(toast.warning).not.toHaveBeenCalled(); }); it('omits the reclaimed figure on full success when the daemon reports zero bytes', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(reclaimPlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ reclaimedBytes: 0, outcomes: [] })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.success).toHaveBeenCalled()); const msg = (toast.success as ReturnType).mock.calls[0][0] as string; expect(msg).not.toMatch(/Freed/); expect(toast.warning).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); }); it('shows the reclaimed figure on full success when the daemon reports bytes', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { if (url === '/system/prune/plan' && opts?.method === 'POST') { return Promise.resolve(jsonResponse(reclaimPlan())); } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576, outcomes: [] })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.success).toHaveBeenCalled()); const msg = (toast.success as ReturnType).mock.calls[0][0] as string; expect(msg).toMatch(/Freed/); }); it('snoozes the banner on dismiss and brings it back when more space is reclaimable', async () => { let usage = reclaimableUsage(1000, 500); // 1500 B total mockedFetch.mockImplementation((url: string) => { if (url === '/system/docker-df') return Promise.resolve(jsonResponse(usage)); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); const { rerender } = render(); await screen.findByTestId('reclaim-hero'); // Dismiss snapshots the current total; the banner hides. await user.click(screen.getByRole('button', { name: /Dismiss hero/ })); await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument()); // A larger reclaimable total on the same node pushes past the snapshot, so // the banner returns. Same node id keeps the snapshot; the new activeNode // object reference triggers a refetch. usage = reclaimableUsage(8000, 2000); // 10000 B total nodesState.activeNode = { id: 1 }; rerender(); await screen.findByTestId('reclaim-hero'); }); it('keeps the banner hidden after dismiss when the reclaimable total does not grow', async () => { mockedFetch.mockImplementation((url: string) => { if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' })); return Promise.resolve(jsonResponse({})); }); const user = userEvent.setup(); const { rerender } = render(); await screen.findByTestId('reclaim-hero'); await user.click(screen.getByRole('button', { name: /Dismiss hero/ })); await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument()); // A stable residue (the same total on the same node) must stay dismissed // across a refetch, not re-nag. Force a refetch via a new activeNode ref. const dfCalls = () => mockedFetch.mock.calls.filter(([u]) => u === '/system/docker-df').length; const before = dfCalls(); nodesState.activeNode = { id: 1 }; rerender(); await waitFor(() => expect(dfCalls()).toBeGreaterThan(before)); expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument(); }); it('hides the banner on a node switch when /settings fails, instead of inheriting the previous node opt-in', async () => { // Both responses key off the active node, so the switch cannot desync them: // node A opts in and serves its own image, node B fails /settings. mockedFetch.mockImplementation((url: string) => { const isNodeA = nodesState.activeNode?.id === 1; if (url === '/settings') { return isNodeA ? Promise.resolve(jsonResponse({ reclaim_hero: '1' })) : Promise.resolve(jsonResponse({}, { ok: false, status: 500 })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') { return Promise.resolve(jsonResponse({ images: [image(isNodeA ? 'node-a-img:latest' : 'node-b-img:latest')], volumes: [], networks: [], })); } return Promise.resolve(jsonResponse({})); }); // Node A has the banner turned on; once loaded it is visible. const { rerender } = render(); await screen.findByText('node-a-img:latest'); await screen.findByTestId('reclaim-hero'); // Switch to node B with a failing /settings: the banner must hide (fail // closed), not carry over node A's enabled state. nodesState.activeNode = { id: 2 }; rerender(); await screen.findByText('node-b-img:latest'); expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument(); }); it('keeps the banner hidden when the setting is explicitly off, even with reclaimable space', async () => { mockedFetch.mockImplementation((url: string) => { if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [image('off-img:latest')], volumes: [], networks: [] })); if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '0' })); return Promise.resolve(jsonResponse({})); }); // Wait on rendered data, not the request: /settings lands in the same batch, // so a visible image proves the setting was applied before this assertion. render(); await screen.findByText('off-img:latest'); expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument(); }); it('badges a rollback-protected image without changing its managed/unused status', async () => { mockedFetch.mockImplementation((url: string) => { if (url === '/system/resources') { return Promise.resolve(jsonResponse({ images: [{ ...image('nginx:1.25'), managedStatus: 'unused', rollbackProtected: true, rollbackProtectionKind: 'stack' }], volumes: [], networks: [], })); } return Promise.resolve(jsonResponse({})); }); render(); await screen.findByText('nginx:1.25'); expect(screen.getByText('Rollback protected')).toBeInTheDocument(); expect(screen.getByText('Unused')).toBeInTheDocument(); }); it('shows rollback generations in the Rollback tab, admin-gated release button included', async () => { mockedFetch.mockImplementation((url: string) => { if (url === '/system/rollback/generations') { return Promise.resolve(jsonResponse([ { id: 'gen-1', shortId: 'abc123456789', stackName: 'seerr', status: 'active', isCurrent: true, phase: 'immediate_verified', createdAt: Date.now(), artifactExpiresAt: null, releasable: true }, ])); } return Promise.resolve(jsonResponse({})); }); render(); await userEvent.click(await screen.findByRole('tab', { name: /rollback/i })); expect(await screen.findByText('seerr')).toBeInTheDocument(); expect(screen.getByText('abc123456789')).toBeInTheDocument(); // State badge and the Current filter pill both render this label. expect(screen.getAllByText('Current').length).toBeGreaterThanOrEqual(2); expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeInTheDocument(); }); });