|
| 1 | +import { render, screen, waitFor, within } from '@testing-library/react' |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | + |
| 4 | +import { EvidenceGaps } from './EvidenceGaps' |
| 5 | +import type { EvidenceGap, EvidenceGapsView } from '../api/types' |
| 6 | + |
| 7 | +/** |
| 8 | + * The screen that ranks what to send next. |
| 9 | + * |
| 10 | + * Everything that can go wrong here is a WORDING failure rather than a rendering |
| 11 | + * one, which is why these tests read the sentences and not just the numbers. |
| 12 | + * |
| 13 | + * IT MUST NOT PROMISE AN OUTCOME. "Would fix 35 findings" is the sentence this |
| 14 | + * page must never say. The scanner has no connection to SAP and does not know |
| 15 | + * what those checks will conclude — that is the entire reason the export is |
| 16 | + * being requested. `it_never_promises_what_the_answers_will_be` pins the verb. |
| 17 | + * |
| 18 | + * THE HEADLINE IS NOT THE COLUMN'S SUM. A finding waiting on two exports is |
| 19 | + * one undecided finding in two rows. A reader who adds the column up and gets |
| 20 | + * a larger number than the headline will trust neither, so the page says which |
| 21 | + * is which in words. |
| 22 | + * |
| 23 | + * A GAP THE CUSTOMER CANNOT CLOSE MUST SAY SO. Ranking an OS-level export |
| 24 | + * first for a RISE customer hands them an item they can only fail. |
| 25 | + * |
| 26 | + * AN EMPTY LIST IS GOOD NEWS HERE, uniquely in this product. Everywhere else |
| 27 | + * an empty list is the ambiguity to be resolved; on this page it means every |
| 28 | + * open finding reached its verdict on complete input, and it must not render |
| 29 | + * as the same shrug. |
| 30 | + */ |
| 31 | + |
| 32 | +vi.mock('../api/client', () => ({ |
| 33 | + evidenceGaps: vi.fn(), |
| 34 | + ApiError: class ApiError extends Error { |
| 35 | + status: number |
| 36 | + constructor(status: number, message: string) { |
| 37 | + super(message) |
| 38 | + this.status = status |
| 39 | + } |
| 40 | + }, |
| 41 | +})) |
| 42 | + |
| 43 | +vi.mock('../lib/title', () => ({ useTitle: () => {} })) |
| 44 | + |
| 45 | +import { evidenceGaps as fetchEvidenceGaps } from '../api/client' |
| 46 | + |
| 47 | +function gap(over: Partial<EvidenceGap> = {}): EvidenceGap { |
| 48 | + return { |
| 49 | + source: 'auth_objects', |
| 50 | + findings_undecided: 35, |
| 51 | + checks: 35, |
| 52 | + systems: 2, |
| 53 | + files_accepted: ['auth_objects.csv'], |
| 54 | + feeds: ['security_params'], |
| 55 | + known_to_loader: true, |
| 56 | + obtainable_in_rise: true, |
| 57 | + ...over, |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +function view(over: Partial<EvidenceGapsView> = {}): EvidenceGapsView { |
| 62 | + return { |
| 63 | + gaps: [gap()], |
| 64 | + findings_undecided: 35, |
| 65 | + unknown_sources: [], |
| 66 | + ...over, |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +const mocked = vi.mocked(fetchEvidenceGaps) |
| 71 | + |
| 72 | +/** The ranked table, scoped. The source name and the undecided count each appear |
| 73 | + * twice on a rendered page — once in the summary tile, once in the row — so an |
| 74 | + * unscoped getByText matches two elements and throws. Scoping also makes the |
| 75 | + * assertions mean what they say: "the row shows this", not "the page mentions |
| 76 | + * it somewhere". */ |
| 77 | +const table = () => within(screen.getByRole('table')) |
| 78 | + |
| 79 | +describe('EvidenceGaps', () => { |
| 80 | + beforeEach(() => { vi.clearAllMocks() }) |
| 81 | + |
| 82 | + it('ranks the sources in the order the API returned them', async () => { |
| 83 | + // THE HEAVY SOURCE IS THE LATER ONE ALPHABETICALLY, deliberately. With the |
| 84 | + // counts the other way round a page that re-sorted by name would produce the |
| 85 | + // identical list, and this test would pass while defending nothing — the |
| 86 | + // mutation that re-sorts survived until these two were swapped. |
| 87 | + mocked.mockResolvedValue(view({ |
| 88 | + gaps: [ |
| 89 | + gap({ source: 'user_groups', findings_undecided: 35 }), |
| 90 | + gap({ source: 'auth_objects', findings_undecided: 3 }), |
| 91 | + ], |
| 92 | + findings_undecided: 38, |
| 93 | + })) |
| 94 | + render(<EvidenceGaps />) |
| 95 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 96 | + const cells = screen.getAllByText(/^(auth_objects|user_groups)$/) |
| 97 | + // Three matches: the "send this first" tile, then the two rows. The tile and |
| 98 | + // the first row must name the same source, or the page contradicts itself. |
| 99 | + expect(cells.map((c) => c.textContent)).toEqual( |
| 100 | + ['user_groups', 'user_groups', 'auth_objects']) |
| 101 | + }) |
| 102 | + |
| 103 | + it('names the heaviest source as the one to send first', async () => { |
| 104 | + mocked.mockResolvedValue(view({ |
| 105 | + gaps: [gap({ source: 'auth_objects', findings_undecided: 35, checks: 35 })], |
| 106 | + })) |
| 107 | + render(<EvidenceGaps />) |
| 108 | + await waitFor(() => expect(screen.getByText('Send this first')).toBeInTheDocument()) |
| 109 | + expect(screen.getByText(/35 findings across 35 checks reach a verdict/)) |
| 110 | + .toBeInTheDocument() |
| 111 | + }) |
| 112 | + |
| 113 | + it('never promises what the answers will be', async () => { |
| 114 | + mocked.mockResolvedValue(view()) |
| 115 | + render(<EvidenceGaps />) |
| 116 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 117 | + const page = document.body.textContent ?? '' |
| 118 | + // "resolve" is absent too: a finding here may well stay open once decided. |
| 119 | + expect(page).not.toMatch(/would fix|will fix|would resolve|fixes \d/i) |
| 120 | + expect(page).toMatch(/reach a verdict/i) |
| 121 | + expect(page).toMatch(/it does not say what they will answer/i) |
| 122 | + }) |
| 123 | + |
| 124 | + it('says the headline is not the sum of the column', async () => { |
| 125 | + mocked.mockResolvedValue(view({ |
| 126 | + gaps: [gap({ source: 'auth_objects', findings_undecided: 2 }), |
| 127 | + gap({ source: 'user_groups', findings_undecided: 2 })], |
| 128 | + // One finding waiting on both: three undecided findings, four row-mentions. |
| 129 | + findings_undecided: 3, |
| 130 | + })) |
| 131 | + render(<EvidenceGaps />) |
| 132 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 133 | + // The tile, not a row: the rows here read 2 and 2, and the headline is 3. |
| 134 | + expect(screen.getByText('Findings undecided').parentElement) |
| 135 | + .toHaveTextContent('3') |
| 136 | + expect(screen.getByText(/counted once here and appears in both rows/)) |
| 137 | + .toBeInTheDocument() |
| 138 | + }) |
| 139 | + |
| 140 | + it('marks a source SAP operates rather than telling the customer to fetch it', |
| 141 | + async () => { |
| 142 | + mocked.mockResolvedValue(view({ |
| 143 | + gaps: [gap({ source: 'ext_os_commands_sap', obtainable_in_rise: false })], |
| 144 | + })) |
| 145 | + render(<EvidenceGaps />) |
| 146 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 147 | + expect(table().getByText('ext_os_commands_sap')).toBeInTheDocument() |
| 148 | + expect(table().getByText(/SAP operates this layer under RISE/)) |
| 149 | + .toBeInTheDocument() |
| 150 | + }) |
| 151 | + |
| 152 | + it('leaves an obtainable source unmarked', async () => { |
| 153 | + // The negative control: without it the assertion above passes on a page that |
| 154 | + // prints that sentence on every row. |
| 155 | + mocked.mockResolvedValue(view()) |
| 156 | + render(<EvidenceGaps />) |
| 157 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 158 | + expect(screen.queryByText(/SAP operates this layer under RISE/)).toBeNull() |
| 159 | + }) |
| 160 | + |
| 161 | + it('calls out a source no export can satisfy as our defect', async () => { |
| 162 | + mocked.mockResolvedValue(view({ |
| 163 | + gaps: [gap({ source: 'auth_objekts', known_to_loader: false, |
| 164 | + files_accepted: [] })], |
| 165 | + unknown_sources: ['auth_objekts'], |
| 166 | + })) |
| 167 | + render(<EvidenceGaps />) |
| 168 | + await waitFor(() => |
| 169 | + expect(screen.getByText(/is not one the loader accepts/)).toBeInTheDocument()) |
| 170 | + expect(screen.getByText(/defect in the check, not something to collect/)) |
| 171 | + .toBeInTheDocument() |
| 172 | + }) |
| 173 | + |
| 174 | + it('shows no defect banner when every source is one we accept', async () => { |
| 175 | + mocked.mockResolvedValue(view()) |
| 176 | + render(<EvidenceGaps />) |
| 177 | + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()) |
| 178 | + expect(screen.queryByText(/is not one the loader accepts/)).toBeNull() |
| 179 | + }) |
| 180 | + |
| 181 | + it('offers the filenames the loader will accept', async () => { |
| 182 | + mocked.mockResolvedValue(view({ |
| 183 | + gaps: [gap({ files_accepted: ['auth_objects.csv', 'tobj.csv'] })], |
| 184 | + })) |
| 185 | + render(<EvidenceGaps />) |
| 186 | + await waitFor(() => |
| 187 | + expect(screen.getByText('auth_objects.csv · tobj.csv')).toBeInTheDocument()) |
| 188 | + }) |
| 189 | + |
| 190 | + it('reads an empty list as the good news it is', async () => { |
| 191 | + mocked.mockResolvedValue(view({ gaps: [], findings_undecided: 0 })) |
| 192 | + render(<EvidenceGaps />) |
| 193 | + await waitFor(() => |
| 194 | + expect(screen.getByText('Nothing outstanding')).toBeInTheDocument()) |
| 195 | + expect(screen.getByText(/reached its verdict on complete input/)) |
| 196 | + .toBeInTheDocument() |
| 197 | + // Not the ambiguous empty state this product spends its time telling apart. |
| 198 | + expect(screen.queryByText(/Ranked by findings waiting/)).toBeNull() |
| 199 | + }) |
| 200 | + |
| 201 | + it('explains a refusal rather than rendering an empty page', async () => { |
| 202 | + const { ApiError } = await import('../api/client') |
| 203 | + mocked.mockRejectedValue(new (ApiError as new (s: number, m: string) => Error)( |
| 204 | + 403, 'forbidden')) |
| 205 | + render(<EvidenceGaps />) |
| 206 | + await waitFor(() => |
| 207 | + expect(screen.getByText(/not permitted to see the estate/)).toBeInTheDocument()) |
| 208 | + }) |
| 209 | +}) |
0 commit comments