diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1de7d..c3a525b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this --- +## [2.1.0] — 2026-06-01 + +### Features + +- **Estimated resolution date per metric** — the compliance asset sidebar now shows each noncompliant metric's estimated resolution date at the top of its section, in `YYYY-MM-DD` format, with placeholders for metrics that have no date set or an invalid date (closes #20) +- **CARD Action Modal** with full owner context +- **Granite Loader Sheet generator** with CARD enrichment, plus a Loader Sheet button on the Reporting page queue panel +- **Vendor-specific issue type dropdown** for Jira ticket creation, with all vendor project keys +- **LIVE and LAST REPORT badges** on the VCL compliance page +- **Collapsible sections** on the Ivanti Queue page and side panel + +### Bug Fixes + +- Fix remediation plan and resolution date missing from the compliance table; format `resolution_date` as `YYYY-MM-DD` +- Improve CARD action error messages and default loader columns +- Fix CARD production timeout by forcing IPv4 (`dns.setDefaultResultOrder('ipv4first')`) +- Add IP address validation to CARD confirm/decline/redirect actions +- Auto-resolve bare IP to CARD asset ID with suffix lookup +- Increase CARD API timeout from 15s to 30s +- Rewrite CARD enrich-batch to use the team assets endpoint for full data + +--- + ## [2.0.0] — 2026-05-26 ### Breaking Changes diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js index 35413af..5e45f10 100644 --- a/frontend/src/components/pages/ComplianceDetailPanel.js +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -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 (
+ {resolutionDisplay && ( +
+ + + {RESOLUTION_DATE_LABEL} + + + {resolutionValueText} + +
+ )}
{resolved && resolved {metric.resolved_on || ''}} diff --git a/frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js b/frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js new file mode 100644 index 0000000..a21f7a2 --- /dev/null +++ b/frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js @@ -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: +//
+// (Calendar icon) +// {RESOLUTION_DATE_LABEL} +// {value | placeholder} +//
+// 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( + {}} + 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']); + }); +}); diff --git a/frontend/src/utils/__tests__/resolutionDate.property.test.js b/frontend/src/utils/__tests__/resolutionDate.property.test.js new file mode 100644 index 0000000..aa38c65 --- /dev/null +++ b/frontend/src/utils/__tests__/resolutionDate.property.test.js @@ -0,0 +1,292 @@ +/** + * Property-Based Tests: Resolution-date helper + * + * Feature: compliance-metric-estimated-resolution-date + * + * Exercises the pure helper `formatResolutionDate(raw)` from + * `frontend/src/utils/resolutionDate.js`, which classifies a raw per-metric + * `resolution_date` value into a discriminated union: + * { state: 'set', value } | { state: 'none' } | { state: 'invalid' } + * + * Library: fast-check (v4) with Jest (react-scripts test). Generators are built + * from fast-check arbitraries only — none are hand-rolled. Each property runs a + * minimum of 100 iterations. + * + * Validates: Requirements 1.1, 1.3, 1.4, 1.6, 2.1, 2.2, 3.2, 3.3, 4.5 + */ + +import * as fc from 'fast-check'; +import { formatResolutionDate } from '../resolutionDate'; + +const NUM_RUNS = 200; +const VALID_STATES = ['set', 'none', 'invalid']; +const SHARED_SENTINEL = 'Multiple values'; + +// --- Independent oracle (NOT the function under test) ---------------------- +// Used only to filter generated inputs so we never assert the wrong class. + +function daysInMonthOracle(year, month) { + const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return lengths[month - 1]; +} + +// True iff `s` is a strict YYYY-MM-DD string that names a real calendar date. +function isValidCalendarYmd(s) { + if (typeof s !== 'string') return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; + const year = Number(s.slice(0, 4)); + const month = Number(s.slice(5, 7)); + const day = Number(s.slice(8, 10)); + if (month < 1 || month > 12) return false; + if (day < 1 || day > daysInMonthOracle(year, month)) return false; + return true; +} + +// --- Shared arbitraries ----------------------------------------------------- + +// Four-digit zero-padded year string (0000–9999) — always matches \d{4}. +const year4Arb = fc.integer({ min: 0, max: 9999 }).map(y => String(y).padStart(4, '0')); + +// Valid calendar dates spanning years, all months, month-length boundaries +// (28/29/30/31), and leap days. The `dim` boundary value guarantees the true +// last day of each month is exercised, including Feb 29 in leap years. +const validDateStringArb = year4Arb.chain(y => + fc.integer({ min: 1, max: 12 }).chain(m => { + const dim = daysInMonthOracle(Number(y), m); + const dayArb = fc.oneof( + fc.constant(1), + fc.constant(dim), + fc.integer({ min: 1, max: dim }) + ); + return dayArb.map( + d => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` + ); + }) +); + +// null / undefined / empty / whitespace-only (spaces, tabs, newlines). +const whitespaceChar = fc.constantFrom(' ', '\t', '\n'); +const whitespaceStringArb = fc + .array(whitespaceChar, { minLength: 1, maxLength: 20 }) + .map(chars => chars.join('')); +const absentArb = fc.oneof( + fc.constantFrom(null, undefined, ''), + whitespaceStringArb +); + +// Non-empty, non-whitespace-only strings that are NOT valid YYYY-MM-DD dates. +// Built from several invalid-by-construction families, then defensively +// filtered against the oracle to drop any accidentally-valid value. +const twoDigitArb = fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0')); +const nonLeapYear4Arb = fc + .integer({ min: 0, max: 9999 }) + .filter(y => !((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0)) + .map(y => String(y).padStart(4, '0')); + +// Valid shape but month out of range (00 or 13–99). +const badMonthArb = fc + .tuple( + year4Arb, + fc.oneof(fc.constant('00'), fc.integer({ min: 13, max: 99 }).map(n => String(n).padStart(2, '0'))), + fc.integer({ min: 1, max: 28 }).map(n => String(n).padStart(2, '0')) + ) + .map(([y, m, d]) => `${y}-${m}-${d}`); + +// Valid shape but day out of range (00 or 32–99). +const badDayArb = fc + .tuple( + year4Arb, + fc.integer({ min: 1, max: 12 }).map(n => String(n).padStart(2, '0')), + fc.oneof(fc.constant('00'), fc.integer({ min: 32, max: 99 }).map(n => String(n).padStart(2, '0'))) + ) + .map(([y, m, d]) => `${y}-${m}-${d}`); + +// Valid shape but impossible calendar day (Feb 30/31, 31 in 30-day months, +// Feb 29 in a non-leap year). +const impossibleDayArb = fc.oneof( + fc.tuple(year4Arb, fc.constant('02'), fc.constantFrom('30', '31')).map(([y, m, d]) => `${y}-${m}-${d}`), + fc.tuple(year4Arb, fc.constantFrom('04', '06', '09', '11'), fc.constant('31')).map(([y, m, d]) => `${y}-${m}-${d}`), + nonLeapYear4Arb.map(y => `${y}-02-29`) +); + +// Wrong shapes (not matching ^\d{4}-\d{2}-\d{2}$). +const wrongShapeArb = fc.oneof( + fc.constantFrom( + '2026-7-1', + '2026-7-01', + '2026-07-1', + '07/01/2026', + '2026/07/01', + '20260701', + '2026-07', + '2026-07-01T00:00:00', + '2026-07-01 ', // trailing handled by trim, still wrong below + 'not-a-date', + 'July 1 2026' + ), + fc.string({ minLength: 1, maxLength: 30 }) +); + +const invalidStringArb = fc + .oneof(wrongShapeArb, badMonthArb, badDayArb, impossibleDayArb, twoDigitArb) + .filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim())); + +// Any input category (used for totality / independence properties). +const anyInputArb = fc.oneof( + validDateStringArb, + absentArb, + invalidStringArb +); + +// --- Property 1 ------------------------------------------------------------- + +// Feature: compliance-metric-estimated-resolution-date, Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD +describe('Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD', () => { + /** + * **Validates: Requirements 1.1, 1.4** + * + * For any valid calendar date in YYYY-MM-DD form, formatResolutionDate + * returns { state: 'set', value } where value matches ^\d{4}-\d{2}-\d{2}$ + * and equals the canonical normalized form (the input itself). + */ + test('valid calendar dates are classified set and normalized to YYYY-MM-DD', () => { + fc.assert( + fc.property(validDateStringArb, dateStr => { + const result = formatResolutionDate(dateStr); + expect(result.state).toBe('set'); + expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(result.value).toBe(dateStr); + }), + { numRuns: NUM_RUNS } + ); + }); +}); + +// --- Property 2 ------------------------------------------------------------- + +// Feature: compliance-metric-estimated-resolution-date, Property 2: Absent values classify as "none" +describe('Property 2: Absent values classify as "none"', () => { + /** + * **Validates: Requirements 2.1, 4.5** + * + * For any input that is null, undefined, the empty string, or a string + * composed entirely of whitespace, formatResolutionDate returns + * { state: 'none' }. + */ + test('null, undefined, empty, and whitespace-only inputs classify as none', () => { + fc.assert( + fc.property(absentArb, raw => { + const result = formatResolutionDate(raw); + expect(result.state).toBe('none'); + }), + { numRuns: NUM_RUNS } + ); + }); +}); + +// --- Property 3 ------------------------------------------------------------- + +// Feature: compliance-metric-estimated-resolution-date, Property 3: Non-empty non-calendar-date values classify as "invalid" +describe('Property 3: Non-empty non-calendar-date values classify as "invalid"', () => { + /** + * **Validates: Requirements 1.6** + * + * For any non-empty, non-whitespace-only string that is not a valid + * YYYY-MM-DD calendar date — wrong shapes, out-of-range months/days, + * impossible days such as 2026-02-30, and arbitrary text — + * formatResolutionDate returns { state: 'invalid' }. + */ + test('non-empty non-calendar-date strings classify as invalid', () => { + fc.assert( + fc.property(invalidStringArb, raw => { + const result = formatResolutionDate(raw); + expect(result.state).toBe('invalid'); + }), + { numRuns: NUM_RUNS } + ); + }); +}); + +// --- Property 4 ------------------------------------------------------------- + +// Feature: compliance-metric-estimated-resolution-date, Property 4: Classification is total over any metric list +describe('Property 4: Classification is total over any metric list', () => { + /** + * **Validates: Requirements 2.2** + * + * For any array of resolution_date values drawn from all categories, + * formatResolutionDate never throws, every result's state is one of + * { 'set', 'none', 'invalid' }, and the number of classified results + * equals the number of inputs. + */ + test('classification is total: no throw, valid state, one result per input', () => { + fc.assert( + fc.property(fc.array(anyInputArb, { maxLength: 30 }), inputs => { + const results = inputs.map(raw => formatResolutionDate(raw)); + expect(results).toHaveLength(inputs.length); + results.forEach(result => { + expect(VALID_STATES).toContain(result.state); + }); + }), + { numRuns: NUM_RUNS } + ); + }); +}); + +// --- Property 5 ------------------------------------------------------------- + +// Metric-like object with an independently chosen resolution_date. +const metricArb = fc.record({ + metric_id: fc.string({ minLength: 1, maxLength: 8 }), + resolution_date: anyInputArb, +}); + +// General metric arrays plus arrays forced to contain two differing dates. +const differingMetricsArb = fc + .tuple(validDateStringArb, validDateStringArb) + .filter(([a, b]) => a !== b) + .chain(([a, b]) => + fc.array(metricArb, { maxLength: 5 }).map(rest => [ + { metric_id: 'm-a', resolution_date: a }, + { metric_id: 'm-b', resolution_date: b }, + ...rest, + ]) + ); +const metricsArrayArb = fc.oneof( + fc.array(metricArb, { maxLength: 30 }), + differingMetricsArb +); + +// Feature: compliance-metric-estimated-resolution-date, Property 5: Each metric's display derives only from its own field (no collapsing) +describe('Property 5: Each metric\'s display derives only from its own field (no collapsing)', () => { + /** + * **Validates: Requirements 1.3, 3.2, 3.3** + * + * For any array of metrics — including arrays where metrics carry different + * resolution_date values — the derived display for each metric equals + * formatResolutionDate applied to that same metric's own field in isolation, + * independent of every sibling, and no result collapses to a shared + * "Multiple values" sentinel. + */ + test('each metric maps to its own field with no shared/collapsed value', () => { + fc.assert( + fc.property(metricsArrayArb, metrics => { + const displayed = metrics.map(m => formatResolutionDate(m.resolution_date)); + expect(displayed).toHaveLength(metrics.length); + + metrics.forEach((metric, index) => { + // Computed in isolation from this metric's own field only. + const isolated = formatResolutionDate(metric.resolution_date); + expect(displayed[index]).toEqual(isolated); + + // No collapsing to a shared "Multiple values" sentinel. + expect(displayed[index].state).not.toBe(SHARED_SENTINEL); + expect(displayed[index].value).not.toBe(SHARED_SENTINEL); + expect(VALID_STATES).toContain(displayed[index].state); + }); + }), + { numRuns: NUM_RUNS } + ); + }); +}); diff --git a/frontend/src/utils/__tests__/resolutionDate.test.js b/frontend/src/utils/__tests__/resolutionDate.test.js new file mode 100644 index 0000000..0cb346d --- /dev/null +++ b/frontend/src/utils/__tests__/resolutionDate.test.js @@ -0,0 +1,88 @@ +/** + * Example and edge-case unit tests for the resolution-date helper. + * + * Spec: .kiro/specs/compliance-metric-estimated-resolution-date + * Task: 1.7 — Example and edge-case unit tests for the helper + * Requirements: 1.1, 1.4, 1.6, 2.1 + * + * These concrete fixtures anchor the contract of `formatResolutionDate` and + * double as regression cases. The universal behavior is covered separately by + * the property-based tests in `resolutionDate.property.test.js`. + */ + +import { + formatResolutionDate, + RESOLUTION_DATE_LABEL, + NO_DATE_PLACEHOLDER, + INVALID_DATE_PLACEHOLDER, +} from '../resolutionDate'; + +describe('formatResolutionDate', () => { + describe('set — valid calendar dates (Requirements 1.1, 1.4)', () => { + it("classifies '2026-07-01' as set with the normalized YYYY-MM-DD value", () => { + expect(formatResolutionDate('2026-07-01')).toEqual({ + state: 'set', + value: '2026-07-01', + }); + }); + + it("classifies the leap day '2024-02-29' as set (2024 is a leap year)", () => { + expect(formatResolutionDate('2024-02-29')).toEqual({ + state: 'set', + value: '2024-02-29', + }); + }); + }); + + describe('invalid — present but not a valid calendar date (Requirement 1.6)', () => { + it("classifies '2026-7-1' as invalid (components not zero-padded)", () => { + expect(formatResolutionDate('2026-7-1')).toEqual({ state: 'invalid' }); + }); + + it("classifies '07/01/2026' as invalid (wrong shape / separators)", () => { + expect(formatResolutionDate('07/01/2026')).toEqual({ state: 'invalid' }); + }); + + it("classifies '2023-02-29' as invalid (2023 is not a leap year)", () => { + expect(formatResolutionDate('2023-02-29')).toEqual({ state: 'invalid' }); + }); + + it("classifies '2026-13-01' as invalid (month out of range)", () => { + expect(formatResolutionDate('2026-13-01')).toEqual({ state: 'invalid' }); + }); + + it("classifies '2026-00-10' as invalid (month below range)", () => { + expect(formatResolutionDate('2026-00-10')).toEqual({ state: 'invalid' }); + }); + + it("classifies '2026-01-32' as invalid (day above month length)", () => { + expect(formatResolutionDate('2026-01-32')).toEqual({ state: 'invalid' }); + }); + }); + + describe('none — absent values (Requirements 2.1)', () => { + it('classifies whitespace-only input as none', () => { + expect(formatResolutionDate(' ')).toEqual({ state: 'none' }); + }); + + it('classifies null as none', () => { + expect(formatResolutionDate(null)).toEqual({ state: 'none' }); + }); + + it('classifies undefined as none', () => { + expect(formatResolutionDate(undefined)).toEqual({ state: 'none' }); + }); + + it('classifies the empty string as none', () => { + expect(formatResolutionDate('')).toEqual({ state: 'none' }); + }); + }); + + describe('display constants', () => { + it('exposes the expected label and placeholder strings', () => { + expect(RESOLUTION_DATE_LABEL).toBe('Est. Resolution'); + expect(NO_DATE_PLACEHOLDER).toBe('not set'); + expect(INVALID_DATE_PLACEHOLDER).toBe('invalid date'); + }); + }); +}); diff --git a/frontend/src/utils/resolutionDate.js b/frontend/src/utils/resolutionDate.js new file mode 100644 index 0000000..64178c8 --- /dev/null +++ b/frontend/src/utils/resolutionDate.js @@ -0,0 +1,86 @@ +/** + * Resolution-date helper — classifies and formats a raw per-metric + * `resolution_date` value for read-only display in the asset sidebar. + * + * Spec: .kiro/specs/compliance-metric-estimated-resolution-date + * Requirements: 1.1, 1.4, 1.6, 2.1 + * + * Pure and deterministic: the result depends only on `raw`. It does not read + * the system clock, timezone, or locale. Validation is strict YYYY-MM-DD with + * a real-calendar-date check (correct month lengths and leap years), which + * matches how the value is produced by the editor. + */ + +// Display constants (single source of truth for component + tests) +export const RESOLUTION_DATE_LABEL = 'Est. Resolution'; +export const NO_DATE_PLACEHOLDER = 'not set'; +export const INVALID_DATE_PLACEHOLDER = 'invalid date'; + +// Strict YYYY-MM-DD shape: four-digit year, two-digit month, two-digit day. +const YMD_SHAPE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Returns the number of days in the given month for the given year, + * accounting for leap years. `month` is 1-based (1 = January). + * + * @param {number} year + * @param {number} month - 1-based month (1–12) + * @returns {number} days in that month + */ +function daysInMonth(year, month) { + // Leap year: divisible by 4, except centuries not divisible by 400. + const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return lengths[month - 1]; +} + +/** + * Classify and format a raw per-metric resolution_date value for display. + * + * @param {string|null|undefined} raw - the metric's resolution_date field + * @returns {{ state: 'set', value: string } | { state: 'none' } | { state: 'invalid' }} + * - { state: 'set', value } the value is a valid calendar date; `value` is YYYY-MM-DD + * - { state: 'none' } the value is null, undefined, empty, or whitespace-only + * - { state: 'invalid' } the value is non-empty but not a valid calendar date + */ +export function formatResolutionDate(raw) { + // Null/undefined → no date set. + if (raw === null || raw === undefined) { + return { state: 'none' }; + } + + // Anything that is not a string is treated as not-a-valid-date once it is + // non-empty; coerce defensively so the helper never throws on bad input. + if (typeof raw !== 'string') { + return { state: 'invalid' }; + } + + const trimmed = raw.trim(); + + // Empty or whitespace-only → no date set. + if (trimmed === '') { + return { state: 'none' }; + } + + // Must match the strict YYYY-MM-DD shape. + if (!YMD_SHAPE.test(trimmed)) { + return { state: 'invalid' }; + } + + // Shape is correct; verify it is a real calendar date. + const year = Number(trimmed.slice(0, 4)); + const month = Number(trimmed.slice(5, 7)); + const day = Number(trimmed.slice(8, 10)); + + if (month < 1 || month > 12) { + return { state: 'invalid' }; + } + + if (day < 1 || day > daysInMonth(year, month)) { + return { state: 'invalid' }; + } + + // Valid calendar date; the trimmed value is already the canonical + // zero-padded YYYY-MM-DD form. + return { state: 'set', value: trimmed }; +}