Files
cve-dashboard/.kiro/specs/compliance-metric-estimated-resolution-date/design.md

245 lines
21 KiB
Markdown
Raw Normal View History

# Design Document
## Overview
This feature surfaces each noncompliant metric's estimated resolution date at the top of that metric's section in the asset sidebar (`ComplianceDetailPanel.js`) on the AEO Compliance page. It is a read and display oriented change confined to the React rendering layer. No backend, API, database, or migration work is required: the per-metric `resolution_date` field is already returned by `GET /api/compliance/items/:hostname` inside `detail.metrics[]`, and it is already editable through the existing **Resolution Date** metadata field and `PATCH /api/compliance/items/:hostname/metadata`.
Today the only place a user sees a resolution date is the editable **Resolution Date** metadata input lower in the sidebar. That input applies to the currently selected metrics and collapses to "Multiple values" whenever the selected metrics disagree, so a viewer cannot see each metric's own projected compliance date at a glance. This feature adds a read-only, per-metric date line to the top of every failing (`status === 'active'`) `MetricRow`, drawn from that same metric's `resolution_date`, formatted as `YYYY-MM-DD`, with explicit placeholders for the no-date and invalid-date cases. The existing editable metadata field and its "Multiple values" logic are left unchanged.
The only non-trivial logic is date interpretation: deciding whether a stored `resolution_date` is a valid calendar date, normalising it to `YYYY-MM-DD`, and distinguishing "no date set" from "a value is present but it is not a valid date." That logic is extracted into a small pure helper so it can be unit- and property-tested independently of React.
**Scope summary:**
- Add a pure date helper (`frontend/src/utils/resolutionDate.js`) that classifies and formats a raw `resolution_date` value.
- Render a read-only estimated-resolution-date line at the top of each active `MetricRow`, above the metric description and all other fields.
- Suppress that line for resolved metrics.
- Leave the editable **Resolution Date** metadata `Section`, `computeSharedValues`, `handleSaveMetadata`, and the metadata PATCH flow untouched.
---
## Architecture
The change lives entirely in the frontend rendering layer. The data already flows from the compliance API into `ComplianceDetailPanel`; this feature only adds a derived, read-only presentation of a field that is already in hand.
```mermaid
flowchart TD
API["GET /api/compliance/items/:hostname<br/>detail.metrics[].resolution_date"] --> Panel["ComplianceDetailPanel<br/>(state: detail)"]
Panel --> Split["activeMetrics = status 'active'<br/>resolvedMetrics = status 'resolved'"]
Split --> Row["MetricRow({ metric, resolved })"]
Row --> Helper["formatResolutionDate(metric.resolution_date)<br/>(pure helper, utils/resolutionDate.js)"]
Helper --> Decide{"resolved?"}
Decide -->|"yes"| Skip["render existing row only<br/>(no estimated date line)"]
Decide -->|"no"| Display["render read-only date line at top of section:<br/>set -> 'YYYY-MM-DD'<br/>none -> 'not set' placeholder<br/>invalid -> 'invalid' placeholder"]
Panel --> Meta["Resolution Date metadata Section<br/>(input + computeSharedValues + handleSaveMetadata)<br/>UNCHANGED"]
Meta --> Patch["PATCH /api/compliance/items/:hostname/metadata"]
Patch -.->|"on success: fetchDetail() re-reads detail"| Panel
```
**Key architectural decisions:**
- **Pure helper for all date logic.** Parsing, validation, and formatting are isolated in `formatResolutionDate`, a pure function with no React, no I/O, and no dependence on the system clock, timezone, or locale. This keeps the rendering code declarative and makes the only branching logic in the feature directly testable. Rationale: `MetricRow` is a presentational function component; mixing date parsing into JSX would be untestable without rendering, and `new Date(...).toLocaleDateString()` would make output depend on the runner's timezone and locale, which the `YYYY-MM-DD` requirement forbids.
- **Display derives from the live `detail` state.** The date line reads `metric.resolution_date` from the same `detail.metrics[]` array that feeds the editable metadata field. Because `handleSaveMetadata` already calls `fetchDetail()` on a successful save, the displayed value updates automatically after an edit with no extra wiring. Rationale: satisfies Requirements 4.2, 4.3, and 4.5 without duplicating state or adding a second source of truth.
- **No new state, no shared/collapsed value.** The per-metric line is computed inline per `MetricRow` from that row's own metric object. It deliberately does not use `computeSharedValues`, so two metrics with different dates each show their own value and never collapse to "Multiple values." Rationale: Requirements 3.2 and 3.3.
- **Read-only by construction.** The new element is plain text (a label plus a value) with no `input`, `button`, `a`, or change handler, so it is identical and non-interactive for every role. Rationale: Requirements 5.15.4. Role-based gating of the existing edit field is unchanged and out of scope.
---
## Components and Interfaces
### `frontend/src/utils/resolutionDate.js` (new)
A pure helper module, following the established `frontend/src/utils/` pattern (for example `queueGrouping.js`). It exports a single classification-and-formatting function and the placeholder strings, so both the component and the tests reference the same constants.
```javascript
/**
* Classify and format a raw per-metric resolution_date value for display.
*
* 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 <input type="date"> editor.
*
* @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) { /* ... */ }
// 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';
```
**Classification rules:**
| Input condition | Result |
|---|---|
| `null` or `undefined` | `{ state: 'none' }` |
| empty string or whitespace-only (after `trim()`) | `{ state: 'none' }` |
| trimmed value matches `^\d{4}-\d{2}-\d{2}$` and is a real calendar date | `{ state: 'set', value: '<YYYY-MM-DD>' }` |
| any other non-empty value (wrong shape, `2026-13-01`, `2026-02-30`, `not-a-date`) | `{ state: 'invalid' }` |
**Validation approach:** the helper trims, tests against the strict `^\d{4}-\d{2}-\d{2}$` shape, then verifies the year/month/day form an actual calendar date (month `112`, day within that month's length, leap-year aware). It avoids `new Date(string)` for the validity decision because that constructor accepts many non-`YYYY-MM-DD` shapes and applies timezone offsets, which would make a date-only field ambiguous. Because the editable field is an `<input type="date">`, well-formed stored values are already `YYYY-MM-DD`; the strict check simply normalises and defends against legacy or malformed values per Requirement 1.6.
### `MetricRow({ metric, resolved, onNavigate })` (modified)
`MetricRow` gains a single read-only block rendered as the first child of the row's content, before the existing top row (`MetricChip` + resolved label), the metric description, the Ivanti ID row, and the highlights list.
- The block renders only when `resolved` is falsy. For resolved metrics (`resolved === true`), `MetricRow` behaves exactly as today (Requirements 3.1, 3.4).
- For active metrics, it calls `formatResolutionDate(metric.resolution_date)` and renders, per the returned `state`:
- `set` — the label and the `YYYY-MM-DD` value (Requirements 1.1, 1.4, 1.5).
- `none` — the label and the `NO_DATE_PLACEHOLDER` text (Requirements 2.1, 4.5).
- `invalid` — the label and the `INVALID_DATE_PLACEHOLDER` text; the remainder of the row still renders (Requirement 1.6).
- The block is positioned at the top of the section using the existing row layout, so it sits above `metric_desc` and all supplementary fields (Requirement 1.2).
- Every active metric renders this block regardless of the `resolution_date` state, and `MetricRow` is still invoked once per metric in `activeMetrics.map(...)`, so all noncompliant metrics continue to render (Requirement 2.2).
No prop signature change is required: `MetricRow` already receives the full `metric` object, which carries `resolution_date`. The `resolved` prop already distinguishes the two call sites.
### `ComplianceDetailPanel` (unchanged)
The container is not modified. The two `MetricRow` call sites remain:
- `activeMetrics.map(m => <MetricRow key={m.metric_id} metric={m} onNavigate={onNavigate} />)` — active metrics show the date line.
- `resolvedMetrics.map(m => <MetricRow key={m.metric_id} metric={m} resolved />)` — resolved metrics do not.
The **Resolution Date** metadata `Section`, the `resolutionDate` state, `computeSharedValues`, `handleSaveMetadata`, the "Multiple values" placeholder logic, and the metadata PATCH call are all left exactly as they are (Requirement 4.1). Because `handleSaveMetadata` already re-runs `fetchDetail()` after a successful save, the new display reflects edits automatically (Requirements 4.2, 4.3); on a failed save the `catch` block sets `metaError` and never updates `detail`, so the previously displayed value is retained (Requirement 4.4).
---
## Data Models
No new persisted data models are introduced. The feature consumes the existing metric shape returned by the compliance API.
**Metric object (existing, as returned in `detail.metrics[]`):**
| Field | Type | Relevance to this feature |
|---|---|---|
| `metric_id` | string (e.g. `"2.3.6i"`) | row key; identifies the metric |
| `category` | string | drives `categoryColor` accent (existing) |
| `status` | `'active'` \| `'resolved'` | `active` = noncompliant (show date line); `resolved` = compliant (suppress) |
| `metric_desc` | string | existing description; date line renders above it |
| `resolution_date` | string \| null | source value for the estimated resolution date display |
| `remediation_plan` | string \| null | unrelated; consumed by existing metadata field |
| `seen_count`, `first_seen`, `resolved_on` | mixed | existing fields, unaffected |
| `extra` | object | existing highlights source, unaffected |
**Helper result model (new, in-memory only):**
```javascript
// Discriminated union returned by formatResolutionDate(raw)
{ state: 'set', value: string } // value is normalised 'YYYY-MM-DD'
{ state: 'none' } // null / empty / whitespace-only
{ state: 'invalid' } // present but not a valid calendar date
```
This object exists only during render and is never persisted or sent to the API.
<!-- Sections from Overview through Data Models complete. Prework on acceptance criteria precedes the Correctness Properties section below. -->
---
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
The testable logic in this feature is concentrated in the pure helper `formatResolutionDate(raw)`. The properties below are universally quantified over its input space and over lists of metrics. Acceptance criteria that concern fixed DOM placement, label presence, role-independent rendering, conditional suppression for resolved metrics, and the save/refetch round-trip are not universal properties of varying input; they are covered by example, render, and integration tests in the Testing Strategy.
The prework consolidated nine property-amenable criteria into five non-redundant properties: criteria 1.1 and 1.4 became Property 1; criteria 2.1 and 4.5 became Property 2; criterion 1.6 became Property 3; criterion 2.2 became Property 4; and criteria 1.3, 3.2, and 3.3 became Property 5.
### Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD
*For any* string that is a valid calendar date in `YYYY-MM-DD` form (correct month range, day within the month's true length, leap-year aware), `formatResolutionDate` returns `{ state: 'set', value }` where `value` is the canonical zero-padded `YYYY-MM-DD` string (matching `^\d{4}-\d{2}-\d{2}$`) equal to the input's normalized form.
**Validates: Requirements 1.1, 1.4**
### Property 2: Absent values classify as "none"
*For any* input that is `null`, `undefined`, the empty string, or a string composed entirely of whitespace, `formatResolutionDate` returns `{ state: 'none' }`.
**Validates: Requirements 2.1, 4.5**
### Property 3: Non-empty non-calendar-date values classify as "invalid"
*For any* non-empty, non-whitespace-only string that is not a valid `YYYY-MM-DD` calendar date — including wrong shapes, out-of-range months or days, impossible calendar days such as `2026-02-30`, and arbitrary text — `formatResolutionDate` returns `{ state: 'invalid' }`.
**Validates: Requirements 1.6**
### Property 4: Classification is total over any metric list
*For any* array of metrics whose `resolution_date` fields are drawn from all input categories (valid dates, `null`, empty, whitespace-only, and malformed strings), `formatResolutionDate` applied to each metric's field never throws and returns exactly one state in `{ 'set', 'none', 'invalid' }` for every metric, and the number of classified results equals the number of metrics.
**Validates: Requirements 2.2**
### Property 5: Each metric's display derives only from its own field (no collapsing)
*For any* array of metrics — including arrays where two or more metrics have different `resolution_date` values — the derived estimated-resolution display for each metric equals `formatResolutionDate` applied to that same metric's own `resolution_date`, independent of every other metric's value, and no result is replaced by a shared or "Multiple values" representation.
**Validates: Requirements 1.3, 3.2, 3.3**
---
## Error Handling
Because the feature is read-only rendering over data already in component state, error handling is about defensive classification rather than failure recovery.
| Condition | Handling |
|---|---|
| `resolution_date` is `null` / `undefined` | `formatResolutionDate` returns `{ state: 'none' }`; the row shows the no-date placeholder (Requirement 2.1). |
| `resolution_date` is empty or whitespace-only | Trimmed to empty → `{ state: 'none' }`; no-date placeholder. |
| `resolution_date` is present but malformed or not a real calendar date | `{ state: 'invalid' }`; the row shows the invalid placeholder and continues rendering the description and all other fields (Requirement 1.6). The helper never throws on bad input. |
| Metric object missing `resolution_date` entirely | Accessing an absent property yields `undefined`, classified as `{ state: 'none' }`. |
| Metadata save fails (existing flow) | The existing `handleSaveMetadata` `catch` sets `metaError` and does not mutate `detail`, so the previously displayed estimated resolution date is retained and an error is shown (Requirement 4.4). No change to this behavior. |
| Detail fetch fails (existing flow) | The existing `error` state renders the panel-level error block; no metric rows (and therefore no date lines) render. Unchanged. |
The helper is the single guard point: every branch of its discriminated union maps to a defined render path, so no metric input can crash `MetricRow` or leave a row partially rendered.
---
## Testing Strategy
This feature uses both property-based tests (for the pure helper's universal behavior) and example/render tests (for fixed DOM structure, conditional suppression, role-independence, and the save round-trip). Property-based testing is appropriate here because `formatResolutionDate` is a pure function over a large input space (arbitrary strings, null, whitespace, malformed and valid dates) with clear universal invariants. It is not appropriate for the placement, label-presence, read-only-structure, and save/refetch criteria, which are fixed behaviors verified more directly by example.
**Property-based tests (helper — `frontend/src/utils/__tests__/resolutionDate.property.test.js`):**
- Library: `fast-check` with Jest (`react-scripts test` / `@testing-library`), matching existing `*.property.test.js` files such as `queue-grouping.property.test.js` and `metricDefinitions.property.test.js`.
- Implemented from scratch is prohibited; use `fast-check` arbitraries.
- Each property runs a minimum of 100 iterations (`{ numRuns: 100 }` or higher, consistent with existing tests that use 100200).
- Each test is tagged with a comment referencing its design property, in the format: **Feature: compliance-metric-estimated-resolution-date, Property {number}: {property_text}**.
- Implement each of the five correctness properties with a single property-based test:
- Property 1 — generator: valid calendar dates spanning years, all months, month-length boundaries (28/29/30/31), and leap days; assert `state === 'set'` and `value` matches `^\d{4}-\d{2}-\d{2}$` and equals the canonical form.
- Property 2 — generator: `fc.constantFrom(null, undefined, '')` combined with whitespace-only strings built from spaces, tabs, and newlines of varying length; assert `state === 'none'`.
- Property 3 — generator: non-empty strings that are not valid `YYYY-MM-DD` dates (wrong shapes, month `00`/`13`+, day `00`/`32`+, `2026-02-30`, arbitrary text), filtered to exclude any accidentally-valid date; assert `state === 'invalid'`.
- Property 4 — generator: arrays mixing all categories; assert no throw, each result's `state` is in `{ 'set', 'none', 'invalid' }`, and result count equals input length.
- Property 5 — generator: arrays of metric-like objects with independently chosen `resolution_date` values (including arrays forced to contain differing dates); assert each metric's derived result deep-equals `formatResolutionDate(metric.resolution_date)` computed in isolation, and that no result is a "Multiple values" sentinel.
**Example and edge-case unit tests (helper):**
- Concrete fixtures that anchor the contract and double as regression cases: `'2026-07-01'``{ state: 'set', value: '2026-07-01' }`; `'2026-7-1'``invalid` (not zero-padded); `'07/01/2026'``invalid`; `'2024-02-29'``set` (leap year); `'2023-02-29'``invalid`; `' '``none`; `null``none`.
**Example / render tests (component — `MetricRow` / `ComplianceDetailPanel`):**
- Using `@testing-library/react`:
- Placement (1.2): an active row with a valid date renders the estimated-resolution element before the description in document order.
- Label presence (1.5): the `RESOLUTION_DATE_LABEL` text appears adjacent to the value for an active row with a valid date.
- No-date and invalid placeholders (2.1, 1.6): active rows with empty and malformed dates render the respective placeholder text and still render the metric description.
- Resolved suppression (3.1, 3.4): a resolved row with a populated date renders no estimated-resolution line; a mixed list shows the line only in active rows.
- Existing editor preserved (4.1): the panel still renders the editable Resolution Date `input[type=date]`.
- Read-only structure (5.3): the date-line subtree contains no `input`, `button`, `a`, or change handler.
- Role-independence (5.1, 5.2, 5.4): rendering under viewer, editor, and admin auth contexts produces identical date-line output, and the new display introduces no editing control.
**Integration / interaction tests (existing save flow):**
- Successful save (4.2, 4.3, 4.5): mock a successful `PATCH` followed by `fetchDetail` returning updated metrics; assert the displayed value updates to the new date, and that clearing the field renders the no-date placeholder.
- Failed save (4.4): mock a failing `PATCH`; assert `detail` is unmodified (previously displayed date retained) and an error indication is shown.
**Build and lint verification:**
- After implementation, run `cd frontend && npm run build` to confirm the production build compiles and ESLint warnings stay within the 25-warning budget. Prefix any intentionally-unused variables with `_` per the project lint rules.
- Run `cd frontend && CI=true npm test` (non-watch) to execute the new property and example tests alongside the existing suite.