Files
sencho/frontend/src/components/__tests__/ResourcesView.test.tsx
T

553 lines
24 KiB
TypeScript

/**
* 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 : (
<div data-testid="reclaim-hero">
<button onClick={onReview}>Review &amp; prune</button>
<button onClick={onDismiss}>Dismiss hero</button>
</div>
),
}));
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<typeof vi.fn>;
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<string, unknown> = {}) {
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<Response>((resolve) => resourcesResolvers.push(resolve));
}
return Promise.resolve(jsonResponse({}));
});
const { rerender } = render(<ResourcesView />);
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(<ResourcesView />);
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<Response>((_resolve, reject) => { rejectResources = reject; });
}
return Promise.resolve(jsonResponse({}));
});
const { unmount } = render(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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<typeof vi.fn>).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(<ResourcesView />);
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<typeof vi.fn>).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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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(<ResourcesView />);
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();
});
});