Refresh compliance list after sidebar metadata save

The host list on the compliance page showed stale resolution date and
remediation plan values after editing them in the detail sidebar, until
an unrelated refresh (filter, team, or tab change) ran. handleSaveMetadata
re-fetched only the panel's own detail and never notified the parent.

Add an onMetadataSaved callback invoked after a successful metadata PATCH
and wire it to the existing list refresh in CompliancePage, mirroring the
onNoteAdded pattern. The list now reflects saved changes immediately.

Closes #23
This commit is contained in:
Jordan Ramos
2026-06-02 11:00:38 -06:00
parent 6cc06390b2
commit 7545457813
4 changed files with 707 additions and 1 deletions

View File

@@ -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 [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -137,6 +137,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
setRemediationPlanEdited(false); setRemediationPlanEdited(false);
// Re-fetch to get updated history // Re-fetch to get updated history
await fetchDetail(); await fetchDetail();
if (onMetadataSaved) onMetadataSaved();
} catch (err) { } catch (err) {
setMetaError(err.message); setMetaError(err.message);
} finally { } finally {

View File

@@ -697,6 +697,7 @@ export default function CompliancePage({ onNavigate }) {
hostname={selectedHost} hostname={selectedHost}
onClose={() => setSelectedHost(null)} onClose={() => setSelectedHost(null)}
onNoteAdded={refresh} onNoteAdded={refresh}
onMetadataSaved={refresh}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
)} )}

View File

@@ -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.11.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 1200. */
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(<CompliancePage onNavigate={() => {}} />);
// 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
);
});

View File

@@ -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 1200. */
const arbRemediationPlan = fc
.string({ minLength: 1, maxLength: 200 })
.filter((s) => s.trim().length > 0);
/** Non-empty, trimmed note text, length-bounded 1200. */
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}`);
/** 13 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(<CompliancePage onNavigate={() => {}} />);
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 "<count>×".
expect(within(row).getByText(`${seenCount}\u00D7`)).toBeInTheDocument();
} finally {
cleanup();
}
}
),
{ numRuns: 8, endOnFailure: true }
);
}, 60000);
});