Files
sencho/frontend/src/components/security/__tests__/HistoryTab.test.tsx
T
Anso 2da87c8c5d feat: add mobile row layout to Security History tab (#1608)
Replace the desktop 8-column table with card-like mobile rows on phones,
following the ImagesTab pattern. Each row shows a 44px checkbox for compare
selection, a severity dot, truncated image ref with trigger and timestamp
meta, count tags (total vulns + fixable, at most 2), and a chevron.

Secret/config-only scans use the FINDINGS dot and suppress the clean tag,
matching the getSeverityKey classification from severityStyles.

Extract a shared HistoryStateMessage component to deduplicate the
loading/error/empty state blocks between the mobile and desktop branches.
2026-07-11 20:57:06 -04:00

260 lines
11 KiB
TypeScript

/**
* HistoryTab is the inline scan-history table that replaced the history sheet.
* Locks: completed-scan fetch on mount with pagination params, Open -> inspect
* on the vulns tab, two-scan compare capped at two with oldest-first baseline
* ordering, search-by-image, and the load-failure error state.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VulnerabilityScan } from '@/types/security';
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(), dismiss: vi.fn() },
}));
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = [];
vi.mock('../../ScanComparisonSheet', () => ({
ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => {
compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId });
return null;
},
}));
vi.mock('../../VulnerabilityScanSheet', () => ({
SeverityChip: ({ severity }: { severity: string }) => <span>{severity}</span>,
}));
import { apiFetch } from '@/lib/api';
import { HistoryTab } from '../HistoryTab';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
return {
id: 1,
node_id: 1,
image_ref: 'alpine:3.19',
image_digest: null,
scanned_at: 1_700_000_000_000,
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
...overrides,
};
}
function listResponse(items: VulnerabilityScan[], total?: number): Response {
return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response;
}
function installMatchMedia(matches: boolean) {
window.matchMedia = vi.fn().mockReturnValue({
matches,
media: '',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as unknown as typeof window.matchMedia;
}
beforeEach(() => {
mockedFetch.mockReset();
compareProps.length = 0;
nodesState.activeNode = { id: 1 };
});
afterEach(() => vi.clearAllMocks());
describe('HistoryTab', () => {
it('fetches completed scans on mount with pagination params', async () => {
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
const url = mockedFetch.mock.calls[0][0] as string;
expect(url).toContain('/security/scans?');
expect(url).toContain('status=completed');
expect(url).toContain('limit=100');
expect(url).toContain('offset=0');
});
it('opens the scan sheet on the vulns tab from Open', async () => {
const onInspect = vi.fn();
mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
render(<HistoryTab onInspect={onInspect} />);
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
});
it('compares two scans with the older as baseline and newer as current', async () => {
const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
mockedFetch.mockResolvedValue(listResponse([newer, older]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
const checks = screen.getAllByLabelText('Select scan to compare');
await userEvent.click(checks[0]);
await userEvent.click(checks[1]);
await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
const last = compareProps[compareProps.length - 1];
expect(last.baselineScanId).toBe(10);
expect(last.currentScanId).toBe(20);
});
it('caps the compare selection at two', async () => {
mockedFetch.mockResolvedValue(listResponse([
scan({ id: 1, image_ref: 'a:1', scanned_at: 3000 }),
scan({ id: 2, image_ref: 'b:1', scanned_at: 2000 }),
scan({ id: 3, image_ref: 'c:1', scanned_at: 1000 }),
]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
const checks = screen.getAllByLabelText('Select scan to compare');
await userEvent.click(checks[0]);
await userEvent.click(checks[1]);
await userEvent.click(checks[2]);
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
});
it('searches by imageIdentityLike as you type (not legacy imageRefLike)', async () => {
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText('Search by image or digest...'), 'redis');
await waitFor(() => {
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
expect(calls.some((u) => u.includes('imageIdentityLike=redis'))).toBe(true);
expect(calls.every((u) => !u.includes('imageRefLike='))).toBe(true);
});
});
it('shows short digest as primary when image_digest is present', async () => {
mockedFetch.mockResolvedValue(listResponse([
scan({
image_ref: 'nginx:1.25',
image_digest: 'sha256:abcdef0123456789ffff',
}),
]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('abcdef012345')).toBeInTheDocument());
expect(screen.getByText('nginx:1.25')).toBeInTheDocument();
});
it('renders the error state when the load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
});
it('treats a malformed 200 response (no items array) as an error, not an empty list', async () => {
mockedFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ oops: true }) } as unknown as Response);
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
});
});
describe('HistoryTab (mobile)', () => {
const original = window.matchMedia;
afterEach(() => { window.matchMedia = original; vi.clearAllMocks(); });
it('renders mobile rows instead of a table', async () => {
installMatchMedia(true);
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
// No table element, no column headers.
expect(document.querySelector('table')).toBeNull();
expect(screen.queryByText('Trigger')).toBeNull();
});
it('opens the scan sheet from a mobile row tap', async () => {
installMatchMedia(true);
const onInspect = vi.fn();
mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
render(<HistoryTab onInspect={onInspect} />);
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
// Click the row's content button (the inspect target), not the checkbox.
const rowBtn = screen.getByRole('button', { name: /nginx:1/ });
await userEvent.click(rowBtn);
expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
});
it('selects two scans via mobile checkboxes and opens compare', async () => {
installMatchMedia(true);
const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
mockedFetch.mockResolvedValue(listResponse([newer, older]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
const checks = screen.getAllByLabelText('Select scan to compare');
await userEvent.click(checks[0]);
await userEvent.click(checks[1]);
await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
const last = compareProps[compareProps.length - 1];
expect(last.baselineScanId).toBe(10);
expect(last.currentScanId).toBe(20);
});
it('does not show clean for a secret-only scan', async () => {
installMatchMedia(true);
mockedFetch.mockResolvedValue(listResponse([scan({
image_ref: 'secret-img:1',
total_vulnerabilities: 0,
secret_count: 2,
misconfig_count: 0,
highest_severity: null,
})]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('secret-img:1')).toBeInTheDocument());
expect(screen.queryByText('clean')).not.toBeInTheDocument();
});
it('shows clean tag for a truly clean scan', async () => {
installMatchMedia(true);
mockedFetch.mockResolvedValue(listResponse([scan({
image_ref: 'clean-img:1',
total_vulnerabilities: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
highest_severity: null,
})]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('clean-img:1')).toBeInTheDocument());
expect(screen.getByText('clean')).toBeInTheDocument();
});
it('renders checkbox with 44px touch target', async () => {
installMatchMedia(true);
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'nginx:1' })]));
render(<HistoryTab onInspect={vi.fn()} />);
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
const checkbox = screen.getByLabelText('Select scan to compare');
expect(checkbox.className).toContain('h-11');
expect(checkbox.className).toContain('w-11');
});
});