diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js index 5e45f10..0a39caf 100644 --- a/frontend/src/components/pages/ComplianceDetailPanel.js +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -44,7 +44,7 @@ function MetricChip({ metricId, category, status }) { ); } -export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) { +export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onMetadataSaved, onNavigate }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -137,6 +137,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, setRemediationPlanEdited(false); // Re-fetch to get updated history await fetchDetail(); + if (onMetadataSaved) onMetadataSaved(); } catch (err) { setMetaError(err.message); } finally { diff --git a/frontend/src/components/pages/CompliancePage.js b/frontend/src/components/pages/CompliancePage.js index 60fb3b0..db379d2 100644 --- a/frontend/src/components/pages/CompliancePage.js +++ b/frontend/src/components/pages/CompliancePage.js @@ -697,6 +697,7 @@ export default function CompliancePage({ onNavigate }) { hostname={selectedHost} onClose={() => setSelectedHost(null)} onNoteAdded={refresh} + onMetadataSaved={refresh} onNavigate={onNavigate} /> )} diff --git a/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.exploration.property.test.js b/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.exploration.property.test.js new file mode 100644 index 0000000..3de23d2 --- /dev/null +++ b/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.exploration.property.test.js @@ -0,0 +1,237 @@ +/** + * Bug Condition Exploration Property Test: + * Compliance List Stale After Sidebar Edit + * + * Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix) + * Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar" + * http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23 + * + * BUG CONDITION (bugfix.md Current Behavior 1.1–1.3): + * isBugCondition(input) = a successful PATCH /api/compliance/items/:hostname/metadata + * occurred from the sidebar (ComplianceDetailPanel). Under this condition the parent + * CompliancePage list is never re-fetched: handleSaveMetadata() does not notify the + * parent (1.1), onClose only clears selectedHost (1.2), and the row keeps the stale + * Resolution Date / Remediation Plan held in the parent `devices` state (1.3). + * + * THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE — failure confirms the bug. + * The property encodes the expected behavior (bugfix.md 2.1, 2.2): for any saved + * metadata value, after a successful sidebar save the parent list row for that + * hostname displays the saved value WITHOUT a manual filter/team/tab change or a + * manual refresh click, because a parent refresh callback re-issues fetchDevices. + * On unfixed code no parent callback fires, so the list GET is never re-issued after + * the PATCH and the row keeps showing the stale value ("—"). + * + * Mirrors the tagging convention of + * backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js + * + * **Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2** + */ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import fc from 'fast-check'; + +// --- Mocks (hoisted by babel-jest above the CompliancePage import) --- + +// Stub auth so CompliancePage renders the STEAM team with write access. +jest.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + canWrite: () => true, + isAdmin: () => false, + getAvailableTeams: () => ['STEAM'], + adminScope: null, + }), +})); + +// The historical charts panel is irrelevant to the list-staleness bug and pulls +// in recharts; stub it so the test stays focused and fast across property runs. +jest.mock('../ComplianceChartsPanel', () => () => null); + +import CompliancePage from '../CompliancePage'; + +const HOSTNAME = 'HOST-001'; +const METRIC_ID = '2.3.6i'; + +// --- Fixtures -------------------------------------------------------------- + +function jsonResponse(body, ok = true) { + return Promise.resolve({ ok, json: async () => body }); +} + +function makeListDevice({ resolution_date, remediation_plan }) { + return { + hostname: HOSTNAME, + ip_address: '10.0.0.1', + device_type: 'Switch', + failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }], + resolution_date, + remediation_plan, + seen_count: 3, + has_notes: false, + }; +} + +function makeDetail() { + return { + hostname: HOSTNAME, + ip_address: '10.0.0.1', + device_type: 'Switch', + team: 'STEAM', + metrics: [ + { + metric_id: METRIC_ID, + category: 'Vulnerability Management', + status: 'active', + metric_desc: 'Outbound encryption required on all endpoints', + resolution_date: null, + remediation_plan: null, + seen_count: 3, + first_seen: '2025-01-01', + resolved_on: null, + extra: {}, + }, + ], + history: [], + notes: [], + }; +} + +/** + * Install a URL-routing global.fetch. + * + * The list device starts stale (resolution_date / remediation_plan = null). Only + * AFTER a successful metadata PATCH does the list endpoint return the saved values — + * modelling the backend, which already persists and returns the new metadata. Thus a + * post-save re-fetch is observable in the row, while on unfixed code (no re-fetch) the + * row keeps showing the stale value. + */ +function installFetchMock(savedResolutionDate, savedRemediationPlan) { + const state = { patchOccurred: false, patchCalls: 0, listCalls: 0, listCallsAfterPatch: 0 }; + + global.fetch = jest.fn((url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); + + // PATCH /compliance/items/:hostname/metadata → success + if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) { + state.patchCalls++; + state.patchOccurred = true; + return jsonResponse({ ok: true }); + } + + // GET /compliance/summary?team=STEAM → minimal valid summary + if (url.includes('/compliance/summary')) { + return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } }); + } + + // GET /compliance/items?team=STEAM&status=active → device list (query string) + if (url.includes('/compliance/items?')) { + state.listCalls++; + if (state.patchOccurred) state.listCallsAfterPatch++; + const device = state.patchOccurred + ? makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan }) + : makeListDevice({ resolution_date: null, remediation_plan: null }); + return jsonResponse({ devices: [device] }); + } + + // GET /compliance/items/:hostname → detail (one active metric) + if (url.includes('/compliance/items/')) { + return jsonResponse(makeDetail()); + } + + // Any other URL (charts panel is mocked out) → safe empty payload + return jsonResponse({}); + }); + + return state; +} + +// --- Generators ------------------------------------------------------------ + +/** + * YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk + * values (mirrors the predecessor exploration test's date-generator pattern). + */ +const arbResolutionDate = fc + .tuple( + fc.integer({ min: 2026, max: 2030 }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1, max: 28 }) + ) + .map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`); + +/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */ +const arbRemediationPlan = fc + .string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0); + +// --- Setup / teardown ------------------------------------------------------ + +beforeEach(() => { + process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; +}); + +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); +}); + +// --- Property Test --------------------------------------------------------- + +describe('Bug Condition Exploration: list row stays stale after sidebar metadata save', () => { + it( + 'Property 1: for any saved metadata, the parent list row reflects the saved Resolution Date after a successful sidebar save (no manual refresh)', + async () => { + await fc.assert( + fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => { + const state = installFetchMock(savedResolutionDate, savedRemediationPlan); + + try { + const { container } = render( {}} />); + + // Wait for the (stale) device row, then capture the row element. + const hostCell = await screen.findByText(HOSTNAME); + const row = hostCell.parentElement; + + // Pre-condition: the stale row does not yet show the to-be-saved date. + expect(within(row).queryByText(savedResolutionDate)).toBeNull(); + + // Open the sidebar detail panel. + fireEvent.click(row); + + // Wait for the panel's editable Resolution Date input to render. + const dateInput = await waitFor(() => { + const el = container.querySelector('input[type="date"]'); + if (!el) throw new Error('resolution date input not ready'); + return el; + }); + const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i); + + // Enter the generated metadata values and click Save. + fireEvent.change(dateInput, { target: { value: savedResolutionDate } }); + fireEvent.change(planInput, { target: { value: savedRemediationPlan } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + // The PATCH is issued on both fixed and unfixed code. + await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0)); + + // PROPERTY (expected behavior, bugfix.md 2.1/2.2): the parent list row + // reflects the saved Resolution Date without a manual refresh. This holds + // only if a parent refresh callback re-issued fetchDevices after the save. + await waitFor( + () => { + expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + // Supporting: the list endpoint was re-issued after the save. + expect(state.listCallsAfterPatch).toBeGreaterThan(0); + } finally { + cleanup(); + } + }), + { numRuns: 10, endOnFailure: true } + ); + }, + 60000 + ); +}); diff --git a/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.preservation.property.test.js b/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.preservation.property.test.js new file mode 100644 index 0000000..32d503e --- /dev/null +++ b/frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.preservation.property.test.js @@ -0,0 +1,467 @@ +/** + * Preservation / Regression Property Tests: + * Compliance List Stale After Sidebar Edit + * + * Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix) + * Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar" + * http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23 + * + * Property 2 (Preservation): the behaviors below must hold AFTER the fix. Following + * observation-first methodology, the preservation properties (note-add refresh, failed + * save surfaces an error without falsely updating the list, close-without-change clears + * selection, other row fields render) were observed to hold on UNFIXED code and must + * keep holding. The "regression guard" (the list row reflects a saved metadata value) + * is the one behavior that flips from failing (pre-fix) to passing (post-fix); it is + * kept here as a standing guard against re-introducing the bug. + * + * Mirrors the tagging convention of + * backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js + * + * Properties tested: + * P2.1 — Regression guard: after a successful sidebar save the list row shows the + * saved Resolution Date with no manual refresh (bugfix.md 2.1, 2.2) + * P2.2 — Note-add still triggers a list re-fetch via onNoteAdded (bugfix.md 3.1) + * P2.3 — Failed metadata save surfaces metaError and does NOT + * falsely update the list row (bugfix.md 3.3) + * P2.4 — Close-without-change clears the selection, no PATCH (bugfix.md 3.4) + * P2.5 — Other row fields (hostname, IP, type, failing metrics, + * seen count) render unchanged (bugfix.md 3.5) + * + * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6** + */ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import fc from 'fast-check'; + +// --- Mocks (hoisted by babel-jest above the CompliancePage import) --- + +// Stub auth so CompliancePage renders the STEAM team with write access. +jest.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + canWrite: () => true, + isAdmin: () => false, + getAvailableTeams: () => ['STEAM'], + adminScope: null, + }), +})); + +// The historical charts panel is irrelevant to the list-staleness bug and pulls +// in recharts; stub it so the tests stay focused and fast across property runs. +jest.mock('../ComplianceChartsPanel', () => () => null); + +import CompliancePage from '../CompliancePage'; + +const HOSTNAME = 'HOST-001'; +const METRIC_ID = '2.3.6i'; + +// --- Fixtures -------------------------------------------------------------- + +function jsonResponse(body, ok = true) { + return Promise.resolve({ ok, json: async () => body }); +} + +function makeListDevice({ resolution_date, remediation_plan }) { + return { + hostname: HOSTNAME, + ip_address: '10.0.0.1', + device_type: 'Switch', + failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }], + resolution_date, + remediation_plan, + seen_count: 3, + has_notes: false, + }; +} + +function makeDetail() { + return { + hostname: HOSTNAME, + ip_address: '10.0.0.1', + device_type: 'Switch', + team: 'STEAM', + metrics: [ + { + metric_id: METRIC_ID, + category: 'Vulnerability Management', + status: 'active', + metric_desc: 'Outbound encryption required on all endpoints', + resolution_date: null, + remediation_plan: null, + seen_count: 3, + first_seen: '2025-01-01', + resolved_on: null, + extra: {}, + }, + ], + history: [], + notes: [], + }; +} + +/** + * Install a URL-routing global.fetch shared by every property. + * + * Options: + * savedResolutionDate / savedRemediationPlan — values the list endpoint returns + * AFTER a successful metadata PATCH (models the backend persisting the new value). + * patchOk — whether PATCH /compliance/items/:hostname/metadata succeeds (default true). + * noteOk — whether POST /compliance/notes succeeds (default true). + * fixedDevice — when provided, the list always returns this device unchanged (used by + * the row-field rendering property which never edits metadata). + * + * The list device starts stale (resolution_date / remediation_plan = null) and only + * returns the saved values once a SUCCESSFUL patch has occurred, so a post-save + * re-fetch is observable in the rendered row. + */ +function installFetchMock(opts = {}) { + const { + savedResolutionDate = '2027-06-15', + savedRemediationPlan = 'Patch firmware', + patchOk = true, + noteOk = true, + fixedDevice = null, + } = opts; + + const state = { + patchOccurred: false, + patchCalls: 0, + noteOccurred: false, + noteCalls: 0, + listCalls: 0, + listCallsAfterPatch: 0, + listCallsAfterNote: 0, + }; + + global.fetch = jest.fn((url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); + + // POST /compliance/notes → success / failure + if (method === 'POST' && url.includes('/compliance/notes')) { + state.noteCalls++; + if (noteOk) { + state.noteOccurred = true; + return jsonResponse({ ok: true, id: 1 }); + } + return jsonResponse({ error: 'Failed to save note' }, false); + } + + // PATCH /compliance/items/:hostname/metadata → success / failure + if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) { + state.patchCalls++; + if (patchOk) { + state.patchOccurred = true; + return jsonResponse({ ok: true }); + } + return jsonResponse({ error: 'Failed to save metadata' }, false); + } + + // GET /compliance/summary?team=STEAM → minimal valid summary + if (url.includes('/compliance/summary')) { + return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } }); + } + + // GET /compliance/items?team=STEAM&status=active → device list (query string) + if (url.includes('/compliance/items?')) { + state.listCalls++; + if (state.patchOccurred) state.listCallsAfterPatch++; + if (state.noteOccurred) state.listCallsAfterNote++; + let device; + if (fixedDevice) { + device = fixedDevice; + } else if (state.patchOccurred) { + device = makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan }); + } else { + device = makeListDevice({ resolution_date: null, remediation_plan: null }); + } + return jsonResponse({ devices: [device] }); + } + + // GET /compliance/items/:hostname → detail (one active metric) + if (url.includes('/compliance/items/')) { + return jsonResponse(makeDetail()); + } + + // Any other URL (charts panel is mocked out) → safe empty payload + return jsonResponse({}); + }); + + return state; +} + +// --- Generators ------------------------------------------------------------ + +/** + * YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk + * values (mirrors the predecessor exploration test's date-generator pattern). + */ +const arbResolutionDate = fc + .tuple( + fc.integer({ min: 2026, max: 2030 }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1, max: 28 }) + ) + .map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`); + +/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */ +const arbRemediationPlan = fc + .string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0); + +/** Non-empty, trimmed note text, length-bounded 1–200. */ +const arbNoteText = fc + .string({ minLength: 1, maxLength: 200 }) + .filter((s) => s.trim().length > 0); + +/** Hostnames like HOST-0001 — unique, regex-safe, distinct from IP/type cells. */ +const arbHostname = fc + .integer({ min: 1, max: 9999 }) + .map((n) => `HOST-${String(n).padStart(4, '0')}`); + +/** IPv4 dotted-quad strings built from integer tuples. */ +const arbIp = fc + .tuple( + fc.integer({ min: 1, max: 254 }), + fc.integer({ min: 0, max: 255 }), + fc.integer({ min: 0, max: 255 }), + fc.integer({ min: 1, max: 254 }) + ) + .map(([a, b, c, d]) => `${a}.${b}.${c}.${d}`); + +const arbDeviceType = fc.constantFrom('Switch', 'Router', 'Firewall', 'Server', 'Workstation'); + +/** metric_id like "7.1.3", built from integer tuples. */ +const arbMetricId = fc + .tuple(fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 })) + .map(([a, b, c]) => `${a}.${b}.${c}`); + +/** 1–3 unique metric ids (unique to avoid duplicate React keys / ambiguous queries). */ +const arbMetricIds = fc.uniqueArray(arbMetricId, { minLength: 1, maxLength: 3 }); + +const arbSeenCount = fc.integer({ min: 1, max: 20 }); + +// --- Helpers --------------------------------------------------------------- + +/** Render CompliancePage, wait for the device row, return { container, row }. */ +async function renderAndGetRow(hostname = HOSTNAME) { + const utils = render( {}} />); + const hostCell = await screen.findByText(hostname); + return { ...utils, row: hostCell.parentElement }; +} + +/** Open the sidebar by clicking the row, wait for the editable date input. */ +async function openPanel(row, container) { + fireEvent.click(row); + const dateInput = await waitFor(() => { + const el = container.querySelector('input[type="date"]'); + if (!el) throw new Error('resolution date input not ready'); + return el; + }); + return dateInput; +} + +// --- Setup / teardown ------------------------------------------------------ + +beforeEach(() => { + process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; +}); + +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); +}); + +// =========================================================================== +// P2.1 — Regression guard: list row reflects a saved Resolution Date +// after a successful sidebar save, with no manual refresh. +// (bugfix.md 2.1, 2.2 — kept as a standing regression guard) +// =========================================================================== +describe('P2.1 — Regression guard: list row reflects saved metadata after a successful save', () => { + it('for any saved metadata, the parent row shows the saved Resolution Date without a manual refresh', async () => { + await fc.assert( + fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => { + const state = installFetchMock({ savedResolutionDate, savedRemediationPlan }); + try { + const { container, row } = await renderAndGetRow(); + + // Pre-condition: the stale row does not yet show the to-be-saved date. + expect(within(row).queryByText(savedResolutionDate)).toBeNull(); + + const dateInput = await openPanel(row, container); + const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i); + + fireEvent.change(dateInput, { target: { value: savedResolutionDate } }); + fireEvent.change(planInput, { target: { value: savedRemediationPlan } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0)); + + // Regression guard: the row reflects the saved value (the fix re-fetches). + await waitFor( + () => { + expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + expect(state.listCallsAfterPatch).toBeGreaterThan(0); + } finally { + cleanup(); + } + }), + { numRuns: 6, endOnFailure: true } + ); + }, 60000); +}); + +// =========================================================================== +// P2.2 — Note-add refresh still works: adding a note re-issues the list GET +// via the existing onNoteAdded callback. (bugfix.md 3.1) +// =========================================================================== +describe('P2.2 — Note-add still triggers a list re-fetch (onNoteAdded preserved)', () => { + it('for any note text, a successful note add re-issues GET /compliance/items', async () => { + await fc.assert( + fc.asyncProperty(arbNoteText, async (noteText) => { + const state = installFetchMock({ noteOk: true }); + try { + const { container, row } = await renderAndGetRow(); + await openPanel(row, container); + + const noteInput = await screen.findByPlaceholderText(/Add a note/i); + fireEvent.change(noteInput, { target: { value: noteText } }); + + const addButton = container.querySelector('.lucide-send').closest('button'); + fireEvent.click(addButton); + + await waitFor(() => expect(state.noteCalls).toBeGreaterThan(0)); + // Preservation: the list is re-fetched after the note add. + await waitFor(() => expect(state.listCallsAfterNote).toBeGreaterThan(0), { timeout: 2000 }); + } finally { + cleanup(); + } + }), + { numRuns: 6, endOnFailure: true } + ); + }, 60000); +}); + +// =========================================================================== +// P2.3 — Failed metadata save surfaces metaError and does NOT falsely +// update the list row. (bugfix.md 3.3) +// =========================================================================== +describe('P2.3 — Failed save shows an error and does not falsely update the list', () => { + it('for any attempted value, a non-OK PATCH surfaces metaError and the row stays stale', async () => { + await fc.assert( + fc.asyncProperty(arbResolutionDate, async (attemptedResolutionDate) => { + const state = installFetchMock({ savedResolutionDate: attemptedResolutionDate, patchOk: false }); + try { + const { container, row } = await renderAndGetRow(); + const dateInput = await openPanel(row, container); + + fireEvent.change(dateInput, { target: { value: attemptedResolutionDate } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0)); + + // The panel surfaces the error from the failed save. + await waitFor(() => expect(screen.getByText('Failed to save metadata')).toBeInTheDocument()); + + // The list row was NOT updated to a value that never persisted. + expect(within(row).queryByText(attemptedResolutionDate)).toBeNull(); + expect(state.listCallsAfterPatch).toBe(0); + } finally { + cleanup(); + } + }), + { numRuns: 6, endOnFailure: true } + ); + }, 60000); +}); + +// =========================================================================== +// P2.4 — Close-without-change clears the selection and issues no PATCH. +// (bugfix.md 3.4) — example-style assertion (no value in varying inputs). +// =========================================================================== +describe('P2.4 — Close without change clears selection and saves nothing', () => { + it('clicking the close (X) removes the panel and triggers no metadata save', async () => { + const state = installFetchMock(); + const { container, row } = await renderAndGetRow(); + + // Open the panel and confirm the editable date input rendered. + await openPanel(row, container); + + // Click the close (X) control without saving anything. + const closeButton = container.querySelector('.lucide-x').closest('button'); + fireEvent.click(closeButton); + + // The panel is gone (selection cleared) — its date input no longer exists. + await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull()); + + // The row remains and no metadata save was attempted. + expect(screen.getByText(HOSTNAME)).toBeInTheDocument(); + expect(state.patchCalls).toBe(0); + }, 30000); + + it('clicking the backdrop also clears selection and triggers no metadata save', async () => { + const state = installFetchMock(); + const { container, row } = await renderAndGetRow(); + + await openPanel(row, container); + + // The first fixed/inset overlay is the backdrop (onClick={onClose}). + const backdrop = container.querySelector('div[style*="position: fixed"]'); + fireEvent.click(backdrop); + + await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull()); + expect(screen.getByText(HOSTNAME)).toBeInTheDocument(); + expect(state.patchCalls).toBe(0); + }, 30000); +}); + +// =========================================================================== +// P2.5 — Other row fields render unchanged: hostname, IP, device type, +// failing metrics, seen count. (bugfix.md 3.5) +// =========================================================================== +describe('P2.5 — Other row fields render correctly and unchanged', () => { + it('for any generated device, the row displays hostname, IP, type, metrics, and seen count', async () => { + await fc.assert( + fc.asyncProperty( + arbHostname, + arbIp, + arbDeviceType, + arbMetricIds, + arbSeenCount, + async (hostname, ip, deviceType, metricIds, seenCount) => { + const device = { + hostname, + ip_address: ip, + device_type: deviceType, + failing_metrics: metricIds.map((id) => ({ metric_id: id, category: 'Vulnerability Management' })), + resolution_date: null, + remediation_plan: null, + seen_count: seenCount, + has_notes: false, + }; + installFetchMock({ fixedDevice: device }); + try { + const { row } = await renderAndGetRow(hostname); + + // Hostname, IP, and device type render verbatim. + expect(within(row).getByText(hostname)).toBeInTheDocument(); + expect(within(row).getByText(ip)).toBeInTheDocument(); + expect(within(row).getByText(deviceType)).toBeInTheDocument(); + + // Every failing-metric badge renders its metric_id. + for (const id of metricIds) { + expect(within(row).getByText(id)).toBeInTheDocument(); + } + + // The seen-count badge renders "×". + expect(within(row).getByText(`${seenCount}\u00D7`)).toBeInTheDocument(); + } finally { + cleanup(); + } + } + ), + { numRuns: 8, endOnFailure: true } + ); + }, 60000); +});