Show estimated resolution date per metric in compliance sidebar

Add a read-only estimated resolution date line at the top of each
noncompliant metric's section in the asset sidebar, sourced from that
metric's own resolution_date. Formats valid dates as YYYY-MM-DD and
shows placeholders for unset and invalid dates. Resolved metrics are
unaffected and the existing editable Resolution Date field is unchanged.

Date classification is isolated in a pure helper (frontend/src/utils/
resolutionDate.js) covered by example and fast-check property tests,
with render and interaction tests for the sidebar.

Closes #20
This commit is contained in:
Jordan Ramos
2026-06-01 15:58:23 -06:00
parent 8224183679
commit 56a4c546d0
6 changed files with 952 additions and 0 deletions

View File

@@ -1,6 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
import ConfirmModal from '../ConfirmModal';
import {
formatResolutionDate,
RESOLUTION_DATE_LABEL,
NO_DATE_PLACEHOLDER,
INVALID_DATE_PLACEHOLDER,
} from '../../utils/resolutionDate';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -796,6 +802,19 @@ function MetricRow({ metric, resolved, onNavigate }) {
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
// Read-only estimated resolution date, shown only for active (noncompliant)
// metrics at the top of the section. Derived solely from this metric's own
// resolution_date — no editing, no shared/"Multiple values" collapsing.
const resolutionDisplay = resolved ? null : formatResolutionDate(metric.resolution_date);
const resolutionValueText = resolutionDisplay
? (resolutionDisplay.state === 'set'
? resolutionDisplay.value
: resolutionDisplay.state === 'invalid'
? INVALID_DATE_PLACEHOLDER
: NO_DATE_PLACEHOLDER)
: null;
const resolutionMuted = resolutionDisplay && resolutionDisplay.state !== 'set';
return (
<div style={{
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
@@ -804,6 +823,23 @@ function MetricRow({ metric, resolved, onNavigate }) {
borderRadius: '0.375rem',
opacity: resolved ? 0.5 : 1,
}}>
{resolutionDisplay && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<Calendar size={12} style={{ color, flexShrink: 0 }} />
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', flexShrink: 0 }}>
{RESOLUTION_DATE_LABEL}
</span>
<span style={{
fontSize: '0.68rem',
color: resolutionMuted ? '#475569' : TEAL,
fontFamily: 'monospace',
fontWeight: resolutionMuted ? '400' : '600',
fontStyle: resolutionMuted ? 'italic' : 'normal',
}}>
{resolutionValueText}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}

View File

@@ -0,0 +1,427 @@
/**
* Render and interaction tests for the per-metric estimated-resolution-date
* line in the asset sidebar (ComplianceDetailPanel.js / MetricRow).
*
* Feature: compliance-metric-estimated-resolution-date
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
*
* Covers tasks 3.2 (placement, labels, placeholders), 3.3 (resolved
* suppression, read-only structure, role-independence, existing editor
* preserved), and 3.4 (existing save round-trip).
*
* Requirements covered: 1.1, 1.2, 1.4, 1.5, 1.6, 2.1, 3.1, 3.4, 4.1, 4.2,
* 4.3, 4.4, 4.5, 5.1, 5.2, 5.3, 5.4.
*
* The component fetches the asset detail via GET
* `${API_BASE}/compliance/items/:hostname` (credentials included) and saves
* metadata via PATCH `${API_BASE}/compliance/items/:hostname/metadata`, so
* global.fetch is mocked to serve the asset detail JSON and PATCH responses.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ComplianceDetailPanel from '../ComplianceDetailPanel';
import {
RESOLUTION_DATE_LABEL,
NO_DATE_PLACEHOLDER,
INVALID_DATE_PLACEHOLDER,
} from '../../../utils/resolutionDate';
const HOSTNAME = 'host-1.example.com';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeMetric(overrides = {}) {
return {
metric_id: '2.3.6i',
category: 'Vulnerability Management',
status: 'active',
metric_desc: 'Outbound encryption required on all endpoints',
resolution_date: null,
remediation_plan: null,
seen_count: 1,
first_seen: '2025-01-01',
resolved_on: null,
extra: {},
...overrides,
};
}
function makeDetail(metrics, overrides = {}) {
return {
hostname: HOSTNAME,
ip_address: '10.0.0.1',
device_type: 'server',
team: 'STEAM',
metrics,
history: [],
notes: [],
...overrides,
};
}
const jsonResponse = (body, ok = true) => ({ ok, json: async () => body });
/**
* Mock global.fetch. GET requests to the detail endpoint are served from a
* queue of detail objects (the last one is reused once the queue drains, so
* the initial load and any re-fetch can each return a distinct snapshot).
* PATCH requests to the metadata endpoint return the configured response.
*/
function mockFetch({ details, patchOk = true, patchBody = {} }) {
const queue = [...details];
let last = details[details.length - 1];
global.fetch = jest.fn((url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
if (method === 'PATCH') {
return Promise.resolve(jsonResponse(patchBody, patchOk));
}
// GET detail (fetchDetail)
const next = queue.length > 0 ? queue.shift() : last;
last = next;
return Promise.resolve(jsonResponse(next));
});
}
// ---------------------------------------------------------------------------
// DOM query helpers
// ---------------------------------------------------------------------------
// The estimated-resolution date line renders as:
// <div>
// <svg .../> (Calendar icon)
// <span>{RESOLUTION_DATE_LABEL}</span>
// <span>{value | placeholder}</span>
// </div>
// We locate each line by its label span, which contains exactly the label text.
function getDateLineLabels(container) {
return Array.from(container.querySelectorAll('span')).filter(
(s) => s.textContent === RESOLUTION_DATE_LABEL
);
}
function getDateLineValueTexts(container) {
return getDateLineLabels(container).map((label) =>
label.nextElementSibling ? label.nextElementSibling.textContent : null
);
}
async function renderAndLoad(detail, props = {}) {
const utils = render(
<ComplianceDetailPanel
hostname={HOSTNAME}
onClose={() => {}}
onNoteAdded={() => {}}
{...props}
/>
);
// Wait for the async detail load to complete (a metric description renders).
await screen.findByText(detail.metrics[0].metric_desc);
return utils;
}
beforeEach(() => {
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
});
afterEach(() => {
jest.restoreAllMocks();
});
// ===========================================================================
// Task 3.2 — Render tests for placement, labels, and placeholders
// ===========================================================================
describe('Task 3.2 — placement, labels, and placeholders', () => {
test('placement (Req 1.2): estimated-resolution element precedes the metric description', async () => {
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
const labelEl = screen.getByText(RESOLUTION_DATE_LABEL);
const descEl = screen.getByText(detail.metrics[0].metric_desc);
// descEl must come AFTER labelEl in document order.
expect(
labelEl.compareDocumentPosition(descEl) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
// Sanity: there is exactly one date line for the single active metric.
expect(getDateLineLabels(container)).toHaveLength(1);
});
test('label presence (Req 1.5): RESOLUTION_DATE_LABEL appears adjacent to the value', async () => {
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
const labels = getDateLineLabels(container);
expect(labels).toHaveLength(1);
// The value span is the immediate next sibling of the label span.
expect(labels[0].nextElementSibling).not.toBeNull();
expect(labels[0].nextElementSibling.textContent).toBe('2026-07-01');
});
test('set value (Req 1.1, 1.4): an active row with 2026-07-01 renders 2026-07-01', async () => {
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
});
test('no-date placeholder (Req 2.1, 4.5): null/empty/whitespace render NO_DATE_PLACEHOLDER and keep the description', async () => {
const metrics = [
makeMetric({
metric_id: '2.3.6i',
metric_desc: 'Metric with null resolution date',
resolution_date: null,
}),
makeMetric({
metric_id: '2.3.8i',
metric_desc: 'Metric with empty resolution date',
resolution_date: '',
}),
makeMetric({
metric_id: 'Vulns_Aging',
metric_desc: 'Metric with whitespace resolution date',
resolution_date: ' ',
}),
];
const detail = makeDetail(metrics);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
// All three active rows show the no-date placeholder.
expect(getDateLineValueTexts(container)).toEqual([
NO_DATE_PLACEHOLDER,
NO_DATE_PLACEHOLDER,
NO_DATE_PLACEHOLDER,
]);
// Each metric's description still renders.
for (const m of metrics) {
expect(screen.getByText(m.metric_desc)).toBeInTheDocument();
}
});
test('invalid placeholder (Req 1.6): malformed date renders INVALID_DATE_PLACEHOLDER and keeps the description', async () => {
const detail = makeDetail([
makeMetric({
metric_desc: 'Metric with a malformed resolution date',
resolution_date: '2026-13-99',
}),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
expect(getDateLineValueTexts(container)).toEqual([INVALID_DATE_PLACEHOLDER]);
expect(
screen.getByText('Metric with a malformed resolution date')
).toBeInTheDocument();
});
});
// ===========================================================================
// Task 3.3 — resolved suppression, read-only structure, role-independence
// ===========================================================================
describe('Task 3.3 — suppression, read-only structure, role-independence', () => {
test('resolved suppression (Req 3.1, 3.4): a resolved metric with a populated date renders no estimated-resolution line', async () => {
const detail = makeDetail([
makeMetric({
metric_id: '2.3.6i',
status: 'resolved',
metric_desc: 'Resolved metric with a populated resolution date',
resolution_date: '2026-07-01',
resolved_on: '2026-06-15',
}),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
// No estimated-resolution line anywhere for a resolved-only asset.
expect(getDateLineLabels(container)).toHaveLength(0);
expect(screen.queryByText(RESOLUTION_DATE_LABEL)).toBeNull();
});
test('mixed list (Req 3.4): the estimated-resolution line appears only within active rows', async () => {
const metrics = [
makeMetric({
metric_id: '2.3.6i',
status: 'active',
metric_desc: 'Active metric one',
resolution_date: '2026-07-01',
}),
makeMetric({
metric_id: '2.3.8i',
status: 'active',
metric_desc: 'Active metric two',
resolution_date: '2026-09-30',
}),
makeMetric({
metric_id: 'Vulns_Aging',
status: 'resolved',
metric_desc: 'Resolved metric',
resolution_date: '2026-01-15',
resolved_on: '2026-01-10',
}),
];
const detail = makeDetail(metrics);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
// Exactly two date lines (one per active metric), each its own value.
expect(getDateLineValueTexts(container)).toEqual([
'2026-07-01',
'2026-09-30',
]);
});
test('read-only structure (Req 5.3): the date-line subtree has no input, button, or anchor', async () => {
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
const labels = getDateLineLabels(container);
expect(labels).toHaveLength(1);
const dateLineSubtree = labels[0].parentElement;
// Plain text only — no interactive controls capable of modifying the field.
expect(
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
).toHaveLength(0);
});
test('role-independence (Req 5.1, 5.2, 5.4): the date line is plain, non-interactive text', async () => {
// ComplianceDetailPanel does not consume a role/auth context for the
// estimated-resolution subtree: the line is derived purely from
// metric.resolution_date and rendered as static spans. The display is
// therefore role-independent by construction — a viewer, editor, and
// admin all receive byte-for-byte identical output and no editing control
// is introduced. We assert the plain-text value and the absence of any
// interactive element in the subtree.
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
const dateLineSubtree = getDateLineLabels(container)[0].parentElement;
expect(
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
).toHaveLength(0);
});
test('existing editor preserved (Req 4.1): the editable Resolution Date input[type=date] still renders', async () => {
const detail = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
mockFetch({ details: [detail] });
const { container } = await renderAndLoad(detail);
expect(container.querySelector('input[type="date"]')).not.toBeNull();
});
});
// ===========================================================================
// Task 3.4 — Interaction tests for the existing save round-trip
// ===========================================================================
describe('Task 3.4 — save round-trip', () => {
test('successful save (Req 4.2, 4.3): displayed estimated-resolution updates to the new date after save + re-fetch', async () => {
const before = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
const after = makeDetail([
makeMetric({ resolution_date: '2026-08-15' }),
]);
// GET (initial) -> before, PATCH -> ok, GET (re-fetch) -> after
mockFetch({ details: [before, after], patchOk: true });
const { container } = await renderAndLoad(before);
// Pre-condition: original date displayed.
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
// Editor updates the editable Resolution Date field and saves.
const dateInput = container.querySelector('input[type="date"]');
fireEvent.change(dateInput, { target: { value: '2026-08-15' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
// The displayed estimated-resolution value updates from the re-fetch.
await waitFor(() => {
expect(getDateLineValueTexts(container)).toEqual(['2026-08-15']);
});
});
test('successful clear (Req 4.5): clearing the field renders NO_DATE_PLACEHOLDER after save + re-fetch', async () => {
const before = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
const after = makeDetail([makeMetric({ resolution_date: '' })]);
mockFetch({ details: [before, after], patchOk: true });
const { container } = await renderAndLoad(before);
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
const dateInput = container.querySelector('input[type="date"]');
fireEvent.change(dateInput, { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(getDateLineValueTexts(container)).toEqual([NO_DATE_PLACEHOLDER]);
});
});
test('failed save (Req 4.4): previously displayed date is retained and an error is shown', async () => {
const before = makeDetail([
makeMetric({ resolution_date: '2026-07-01' }),
]);
// PATCH fails; fetchDetail is never re-issued, so the queue only needs the
// initial detail.
mockFetch({
details: [before],
patchOk: false,
patchBody: { error: 'Save failed' },
});
const { container } = await renderAndLoad(before);
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
const dateInput = container.querySelector('input[type="date"]');
fireEvent.change(dateInput, { target: { value: '2099-01-01' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
// Error indication appears.
await screen.findByText('Save failed');
// The previously displayed estimated-resolution date is retained.
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
});
});