mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
2da87c8c5d
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.
260 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|