Compare commits
2 Commits
ada9df26a8
...
a61d254ff9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61d254ff9 | ||
|
|
8ebd7e4d5e |
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Migration Registration Reminder",
|
||||
"description": "When a new migration file is created in backend/migrations/, reminds the agent to add it to the POSTGRES_MIGRATIONS array in run-all.js so the pipeline will execute it during deploy.",
|
||||
"version": "1",
|
||||
"name": "Migration Registration Check",
|
||||
"description": "After any write to backend/migrations/, verify the file is registered in POSTGRES_MIGRATIONS array in run-all.js. Blocks until confirmed.",
|
||||
"version": "2",
|
||||
"when": {
|
||||
"type": "fileCreated",
|
||||
"patterns": [
|
||||
"backend/migrations/*.js"
|
||||
]
|
||||
"type": "postToolUse",
|
||||
"toolTypes": ["write"]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "A new migration file was created. If this migration uses the Postgres pool (require('../db')), add its filename to the POSTGRES_MIGRATIONS array in backend/migrations/run-all.js so the CI/CD pipeline will run it automatically during deploy. Skip this if the file is run-all.js itself."
|
||||
"prompt": "STOP. If the tool just wrote or created a file matching backend/migrations/*.js (but NOT run-all.js itself), you MUST immediately:\n1. Read backend/migrations/run-all.js\n2. Check if the migration filename exists in the POSTGRES_MIGRATIONS array\n3. If NOT present, add it to the end of the array RIGHT NOW before doing anything else\n4. Do NOT proceed with other work until this is done\n\nThis is a hard requirement — migrations not in run-all.js will not run in CI/CD and will break production deploys. If the written file is not a migration file, ignore this message."
|
||||
}
|
||||
}
|
||||
1
.kiro/specs/archer-template-library/.config.kiro
Normal file
1
.kiro/specs/archer-template-library/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "69cc3734-7a9a-4d50-be5e-6e47a2806651", "workflowType": "requirements-first", "specType": "feature"}
|
||||
518
.kiro/specs/archer-template-library/design.md
Normal file
518
.kiro/specs/archer-template-library/design.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Design Document: Archer Template Library
|
||||
|
||||
## Overview
|
||||
|
||||
The Archer Template Library adds a template management system to the STEAM Security Dashboard, allowing editors to store, browse, and reuse pre-filled content for Archer Risk Acceptance forms. Templates are organized by a Vendor > Platform > Model hierarchy and contain the static and semi-static content sections that map directly to the external Archer eGRC application's form fields.
|
||||
|
||||
The feature integrates into the existing Ivanti Todo Queue page where Archer workflow items are worked. When an editor selects an Archer queue item, they can pick a template, view its sections, and copy content to their clipboard for pasting into the external Archer application.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
1. **Separate route module** (`routes/archerTemplates.js`) following the project's factory pattern — keeps template CRUD isolated from existing `archerTickets.js` which handles EXC number tracking.
|
||||
2. **PostgreSQL table** with a composite unique index on `(LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))` to enforce case-insensitive uniqueness at the database level.
|
||||
3. **Single page component** (`ArcherTemplatePage.js`) for the Template Manager, plus a **reusable TemplateSelector component** embedded in the existing `IvantiTodoQueuePage.js` for the queue workflow integration.
|
||||
4. **No separate search endpoint** — the list endpoint handles search/filter via query parameters, keeping the API surface small.
|
||||
5. **Clipboard API** (`navigator.clipboard.writeText`) for copy operations — the dashboard already runs on HTTPS in production.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Frontend
|
||||
TM[ArcherTemplatePage.js<br/>Template Manager]
|
||||
TS[TemplateSelector.js<br/>Queue Integration]
|
||||
IQ[IvantiTodoQueuePage.js]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
R[routes/archerTemplates.js<br/>Express Router]
|
||||
A[helpers/auditLog.js]
|
||||
DB[(PostgreSQL<br/>archer_templates)]
|
||||
end
|
||||
|
||||
TM -->|fetch /api/archer-templates| R
|
||||
TS -->|fetch /api/archer-templates| R
|
||||
IQ -->|embeds| TS
|
||||
R -->|pool.query| DB
|
||||
R -->|logAudit| A
|
||||
A -->|fire-and-forget INSERT| DB
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. Frontend component calls `/api/archer-templates/*` with session cookie
|
||||
2. `requireAuth()` middleware validates session, attaches `req.user`
|
||||
3. Write operations additionally pass through `requireGroup('Admin', 'Standard_User')`
|
||||
4. Route handler validates input, executes PostgreSQL query via `pool`
|
||||
5. On success of write operations, `logAudit()` is called fire-and-forget
|
||||
6. Response returned to frontend
|
||||
|
||||
---
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: `routes/archerTemplates.js`
|
||||
|
||||
Factory function signature:
|
||||
|
||||
```javascript
|
||||
function createArcherTemplatesRouter() {
|
||||
// Returns Express Router
|
||||
// Imports pool from '../db', auth from '../middleware/auth', logAudit from '../helpers/auditLog'
|
||||
}
|
||||
```
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/archer-templates` | requireAuth | List/search/filter templates |
|
||||
| `GET` | `/api/archer-templates/:id` | requireAuth | Get single template by ID |
|
||||
| `POST` | `/api/archer-templates` | requireAuth + requireGroup | Create template |
|
||||
| `PUT` | `/api/archer-templates/:id` | requireAuth + requireGroup | Update template |
|
||||
| `DELETE` | `/api/archer-templates/:id` | requireAuth + requireGroup | Delete template |
|
||||
| `POST` | `/api/archer-templates/:id/clone` | requireAuth + requireGroup | Clone template |
|
||||
| `GET` | `/api/archer-templates/hierarchy/vendors` | requireAuth | Distinct vendors list |
|
||||
| `GET` | `/api/archer-templates/hierarchy/platforms` | requireAuth | Distinct platforms for vendor |
|
||||
| `GET` | `/api/archer-templates/hierarchy/models` | requireAuth | Distinct models for vendor+platform |
|
||||
|
||||
#### `GET /api/archer-templates`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `search` | string | Substring match across vendor, platform, model (case-insensitive). Ignored if empty/whitespace-only. |
|
||||
| `vendor` | string | Exact match filter on vendor (case-insensitive) |
|
||||
| `platform` | string | Exact match filter on platform (case-insensitive) |
|
||||
| `model` | string | Exact match filter on model (case-insensitive) |
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"vendor": "Harmonic",
|
||||
"platform": "vCMTS",
|
||||
"model": "3.29.1",
|
||||
"environment_overview": "...",
|
||||
"segmentation": "...",
|
||||
"mitigating_controls": "...",
|
||||
"additional_info": "...",
|
||||
"charter_network_banner": "...",
|
||||
"data_classification": "...",
|
||||
"charter_network": "...",
|
||||
"additional_access_list": "...",
|
||||
"created_by": 3,
|
||||
"created_at": "2025-01-15T10:30:00Z",
|
||||
"updated_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Filtering logic:**
|
||||
- `search` applies as `ILIKE '%value%'` across vendor OR platform OR model
|
||||
- Field-specific filters apply as `LOWER(TRIM(field)) = LOWER(TRIM(value))`
|
||||
- When both search and filters are present, they combine with AND logic
|
||||
- Results always sorted by `vendor ASC, platform ASC, model ASC`
|
||||
|
||||
#### `POST /api/archer-templates`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"vendor": "Harmonic",
|
||||
"platform": "vCMTS",
|
||||
"model": "3.29.1",
|
||||
"environment_overview": "content...",
|
||||
"segmentation": "content...",
|
||||
"mitigating_controls": "",
|
||||
"additional_info": "",
|
||||
"charter_network_banner": "",
|
||||
"data_classification": "",
|
||||
"charter_network": "",
|
||||
"additional_access_list": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `vendor`, `platform`, `model`: required, 1-100 chars after trim, non-empty after trim
|
||||
- Section fields: optional, max 10,000 chars each, default to empty string
|
||||
- Uniqueness: `LOWER(TRIM(vendor)) + LOWER(TRIM(platform)) + LOWER(TRIM(model))` must be unique
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"vendor": "Harmonic",
|
||||
"platform": "vCMTS",
|
||||
"model": "3.29.1",
|
||||
"environment_overview": "content...",
|
||||
"...": "...",
|
||||
"created_by": 3,
|
||||
"created_at": "2025-01-15T10:30:00Z",
|
||||
"updated_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400` — missing or invalid fields (includes field names in error message)
|
||||
- `409` — duplicate vendor/platform/model combination
|
||||
|
||||
#### `PUT /api/archer-templates/:id`
|
||||
|
||||
**Request Body:** Partial — only provided fields are updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor": "Harmonic",
|
||||
"mitigating_controls": "updated content..."
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:** Same rules as create for any provided field. If vendor/platform/model change, uniqueness is re-checked against other templates (excluding self).
|
||||
|
||||
**Response:** `200 OK` — returns full updated template object.
|
||||
|
||||
**Error Responses:**
|
||||
- `400` — invalid field values
|
||||
- `404` — template ID not found
|
||||
- `409` — new vendor/platform/model combination conflicts with another template
|
||||
|
||||
#### `DELETE /api/archer-templates/:id`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{ "message": "Template deleted successfully" }
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404` — template ID not found
|
||||
|
||||
#### `POST /api/archer-templates/:id/clone`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"vendor": "Harmonic",
|
||||
"platform": "vCMTS",
|
||||
"model": "3.30.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- All three hierarchy fields required
|
||||
- At least one must differ from source (enforced by uniqueness constraint)
|
||||
- Same length/format validation as create
|
||||
|
||||
**Response:** `201 Created` — returns full new template object with copied section content.
|
||||
|
||||
**Error Responses:**
|
||||
- `400` — missing/invalid hierarchy fields
|
||||
- `404` — source template not found
|
||||
- `409` — target combination already exists
|
||||
|
||||
#### `GET /api/archer-templates/hierarchy/vendors`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
["Adtran", "Harmonic", "Vecima"]
|
||||
```
|
||||
|
||||
#### `GET /api/archer-templates/hierarchy/platforms?vendor=Harmonic`
|
||||
|
||||
**Query Parameters:** `vendor` (required)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
["RPD", "vCMTS"]
|
||||
```
|
||||
|
||||
**Error:** `400` if `vendor` param missing.
|
||||
|
||||
#### `GET /api/archer-templates/hierarchy/models?vendor=Harmonic&platform=vCMTS`
|
||||
|
||||
**Query Parameters:** `vendor` (required), `platform` (required)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
["3.29.1", "3.30.0"]
|
||||
```
|
||||
|
||||
**Error:** `400` if either param missing.
|
||||
|
||||
---
|
||||
|
||||
### Frontend: `ArcherTemplatePage.js`
|
||||
|
||||
Full-page Template Manager component at `frontend/src/components/pages/ArcherTemplatePage.js`.
|
||||
|
||||
**Component Structure:**
|
||||
|
||||
```
|
||||
ArcherTemplatePage
|
||||
├── Header (title + create button)
|
||||
├── TemplateList (grouped by vendor > platform)
|
||||
│ ├── VendorGroup (collapsible)
|
||||
│ │ ├── PlatformSubgroup
|
||||
│ │ │ └── TemplateRow (model, edit/clone/delete buttons)
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
├── TemplateFormModal (create/edit/clone)
|
||||
│ ├── Hierarchy fields (vendor, platform, model)
|
||||
│ └── Section textareas (8 sections with labels)
|
||||
└── DeleteConfirmModal
|
||||
```
|
||||
|
||||
**State Management:** Local `useState` — no global state needed. Template list fetched on mount and after mutations.
|
||||
|
||||
**Role-based rendering:** Uses `useAuth().canWrite()` to conditionally show action buttons. Viewers see the list but no create/edit/delete/clone controls.
|
||||
|
||||
---
|
||||
|
||||
### Frontend: `TemplateSelector.js`
|
||||
|
||||
Reusable component embedded in `IvantiTodoQueuePage.js` when viewing an Archer workflow item.
|
||||
|
||||
**Component Structure:**
|
||||
|
||||
```
|
||||
TemplateSelector
|
||||
├── SearchableDropdown
|
||||
│ ├── Search input
|
||||
│ └── Filtered template list
|
||||
├── SectionPanel (shown after selection)
|
||||
│ ├── StaticSections (Environment Overview, Segmentation, Mitigating Controls)
|
||||
│ ├── SemiStaticSections (Additional Info, Charter Network Banner, etc.)
|
||||
│ └── CopyAllButton
|
||||
└── SectionBlock (repeated for each section)
|
||||
├── Label
|
||||
├── Content (or "No content stored" placeholder)
|
||||
└── CopyButton (with confirmation state)
|
||||
```
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
// No props needed — fetches templates from API independently
|
||||
// Appears as an expandable panel within the Archer queue item view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### Database Table: `archer_templates`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS archer_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
platform VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
environment_overview TEXT NOT NULL DEFAULT '',
|
||||
segmentation TEXT NOT NULL DEFAULT '',
|
||||
mitigating_controls TEXT NOT NULL DEFAULT '',
|
||||
additional_info TEXT NOT NULL DEFAULT '',
|
||||
charter_network_banner TEXT NOT NULL DEFAULT '',
|
||||
data_classification TEXT NOT NULL DEFAULT '',
|
||||
charter_network TEXT NOT NULL DEFAULT '',
|
||||
additional_access_list TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Case-insensitive uniqueness on trimmed vendor/platform/model
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
||||
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)));
|
||||
|
||||
-- Index for list ordering and search
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor ON archer_templates(vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform ON archer_templates(platform);
|
||||
```
|
||||
|
||||
### Migration File: `backend/migrations/add_archer_templates_table.js`
|
||||
|
||||
Follows the project's migration pattern — idempotent, uses `IF NOT EXISTS`.
|
||||
|
||||
### Section Field Mapping
|
||||
|
||||
| DB Column | Archer Form Label | Type |
|
||||
|-----------|------------------|------|
|
||||
| `environment_overview` | Environment Overview | Static |
|
||||
| `segmentation` | Segmentation | Static |
|
||||
| `mitigating_controls` | Mitigating Controls | Static |
|
||||
| `additional_info` | Additional Info/Background | Semi-Static |
|
||||
| `charter_network_banner` | Charter Network Banner | Semi-Static |
|
||||
| `data_classification` | Data Classification | Semi-Static |
|
||||
| `charter_network` | Charter Network | Semi-Static |
|
||||
| `additional_access_list` | Additional Access List | Semi-Static |
|
||||
|
||||
---
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: Template data round-trip preservation
|
||||
|
||||
*For any* valid template with vendor (1-100 chars), platform (1-100 chars), model (1-100 chars), and section content (0-10,000 chars each), creating the template via the API and then retrieving it by ID SHALL return the exact same field values (after trimming vendor/platform/model whitespace).
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 1.5, 2.1**
|
||||
|
||||
### Property 2: Uniqueness enforcement across all write operations
|
||||
|
||||
*For any* two templates where `LOWER(TRIM(vendor))`, `LOWER(TRIM(platform))`, and `LOWER(TRIM(model))` are identical, the system SHALL reject the second operation (create, update, or clone) with a 409 status code, regardless of case or leading/trailing whitespace differences.
|
||||
|
||||
**Validates: Requirements 1.3, 2.2, 2.8, 3.2, 3.5**
|
||||
|
||||
### Property 3: Input validation rejects invalid hierarchy fields
|
||||
|
||||
*For any* template create or update request where vendor, platform, or model is empty after trimming, consists only of whitespace, or exceeds 100 characters, the API SHALL reject the request with a 400 status code and an error message identifying which field(s) failed validation.
|
||||
|
||||
**Validates: Requirements 1.6, 2.3**
|
||||
|
||||
### Property 4: List and search results are sorted
|
||||
|
||||
*For any* set of templates in the database and any combination of search/filter parameters, the API response SHALL return results ordered by vendor ascending, then platform ascending, then model ascending (lexicographic, case-insensitive).
|
||||
|
||||
**Validates: Requirements 2.4, 6.4**
|
||||
|
||||
### Property 5: Search and filter semantics with AND logic
|
||||
|
||||
*For any* search query (substring match on vendor/platform/model) combined with any field-specific exact-match filters (vendor, platform, model), the API SHALL return only templates that satisfy ALL conditions simultaneously: the search substring appears in at least one hierarchy field AND each provided filter exactly matches its respective field (case-insensitive).
|
||||
|
||||
**Validates: Requirements 2.5, 2.6, 6.1, 6.2, 6.3**
|
||||
|
||||
### Property 6: Partial update preserves unspecified fields
|
||||
|
||||
*For any* existing template and any subset of updatable fields provided in a PUT request, the API SHALL modify only the specified fields and leave all other fields unchanged, while setting `updated_at` to the current timestamp.
|
||||
|
||||
**Validates: Requirements 2.7**
|
||||
|
||||
### Property 7: Delete removes template permanently
|
||||
|
||||
*For any* existing template, after a successful DELETE request, a subsequent GET request for that template's ID SHALL return 404.
|
||||
|
||||
**Validates: Requirements 2.9**
|
||||
|
||||
### Property 8: Write operations require editor or admin role
|
||||
|
||||
*For any* user with viewer role (or no authenticated session), all write operations (POST create, PUT update, DELETE, POST clone) SHALL be rejected with 401 (unauthenticated) or 403 (insufficient permissions).
|
||||
|
||||
**Validates: Requirements 2.10, 4.13**
|
||||
|
||||
### Property 9: Non-existent template ID returns 404
|
||||
|
||||
*For any* template ID that does not exist in the database, GET, PUT, DELETE, and clone requests targeting that ID SHALL return a 404 status code.
|
||||
|
||||
**Validates: Requirements 2.12, 3.4**
|
||||
|
||||
### Property 10: Clone preserves all section content
|
||||
|
||||
*For any* existing template, cloning it with new vendor/platform/model values SHALL produce a new template whose 8 section content fields are byte-for-byte identical to the source template, with a different ID, new `created_at` timestamp, and `created_by` set to the requesting user.
|
||||
|
||||
**Validates: Requirements 3.1, 3.3**
|
||||
|
||||
### Property 11: Hierarchy endpoints return distinct sorted values
|
||||
|
||||
*For any* set of templates, the vendors endpoint SHALL return a deduplicated, alphabetically sorted array of vendor names; the platforms endpoint (given a vendor) SHALL return only distinct platforms from templates matching that vendor; and the models endpoint (given vendor+platform) SHALL return only distinct models from matching templates — all sorted alphabetically ascending.
|
||||
|
||||
**Validates: Requirements 7.1, 7.2, 7.3**
|
||||
|
||||
### Property 12: Copy All concatenation format
|
||||
|
||||
*For any* template with a mix of populated and empty sections, the "Copy All" operation SHALL produce a string that concatenates only non-empty sections, each preceded by its human-readable section header, in the order: static sections first (Environment Overview, Segmentation, Mitigating Controls) then semi-static sections (Additional Info/Background, Charter Network Banner, Data Classification, Charter Network, Additional Access List).
|
||||
|
||||
**Validates: Requirements 5.9**
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Error Strategy
|
||||
|
||||
| Scenario | HTTP Status | Error Response Format |
|
||||
|----------|-------------|----------------------|
|
||||
| Missing required field | 400 | `{ "error": "Vendor is required" }` or `{ "error": "Missing fields: vendor, model" }` |
|
||||
| Field too long | 400 | `{ "error": "Vendor must be 100 characters or fewer" }` |
|
||||
| Section too long | 400 | `{ "error": "environment_overview must be 10,000 characters or fewer" }` |
|
||||
| Duplicate combination | 409 | `{ "error": "A template with this vendor/platform/model combination already exists" }` |
|
||||
| Template not found | 404 | `{ "error": "Template not found" }` |
|
||||
| Not authenticated | 401 | `{ "error": "Authentication required" }` |
|
||||
| Insufficient permissions | 403 | `{ "error": "Insufficient permissions", "required": [...], "current": "..." }` |
|
||||
| Database error | 500 | `{ "error": "Internal server error" }` |
|
||||
|
||||
### Frontend Error Handling
|
||||
|
||||
- **Network errors:** Caught in try/catch around `fetch()`, displayed as a banner message within the component.
|
||||
- **Validation errors (400):** Displayed inline next to the relevant form field or as a general form error.
|
||||
- **Conflict errors (409):** Displayed as a warning banner at the top of the form indicating the duplicate.
|
||||
- **Auth errors (401/403):** Handled by AuthContext — 401 triggers redirect to login, 403 shows permission denied message.
|
||||
- **Clipboard failures:** If `navigator.clipboard.writeText` rejects (e.g., permissions denied), display a fallback message suggesting manual copy.
|
||||
|
||||
### Audit Log Resilience
|
||||
|
||||
Per requirement 8.5, `logAudit()` is called fire-and-forget. If the audit insert fails, the error is logged to `console.error` but the main operation's response is unaffected. This matches the existing pattern in `helpers/auditLog.js`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests (fast-check)
|
||||
|
||||
The project uses `fast-check` for property-based testing (visible in existing `__tests__/*.property.test.js` files). Each correctness property above maps to one property-based test with a minimum of 100 iterations.
|
||||
|
||||
**Test file:** `backend/__tests__/archer-template-library.property.test.js`
|
||||
|
||||
**Properties to implement:**
|
||||
|
||||
| Property | Test Description |
|
||||
|----------|-----------------|
|
||||
| 1 | Template data round-trip — create + GET preserves all fields |
|
||||
| 2 | Uniqueness enforcement — duplicate combinations rejected across create/update/clone |
|
||||
| 3 | Input validation — invalid hierarchy fields rejected with 400 |
|
||||
| 4 | Sorted results — list/search always returns sorted by vendor/platform/model |
|
||||
| 5 | Search + filter AND logic — combined criteria narrow results correctly |
|
||||
| 6 | Partial update semantics — unspecified fields preserved |
|
||||
| 7 | Delete permanence — deleted templates return 404 |
|
||||
| 8 | Access control — viewer role cannot perform write operations |
|
||||
| 9 | Non-existent ID — 404 for all operations on missing templates |
|
||||
| 10 | Clone content preservation — cloned sections match source |
|
||||
| 11 | Hierarchy distinct values — deduplicated and sorted |
|
||||
| 12 | Copy All format — concatenation includes only non-empty sections in order |
|
||||
|
||||
**Tag format:** Each test tagged with `// Feature: archer-template-library, Property N: <property text>`
|
||||
|
||||
**Configuration:** 100 iterations minimum per property.
|
||||
|
||||
### Unit / Example-Based Tests
|
||||
|
||||
**Test file:** `backend/__tests__/archer-template-library.test.js`
|
||||
|
||||
- Template creation with all sections populated
|
||||
- Template creation with no sections (defaults to empty strings)
|
||||
- Timestamp metadata correctness (created_at, updated_at, created_by)
|
||||
- Clone metadata (new created_at, new created_by)
|
||||
- Hierarchy endpoint without required params returns 400
|
||||
- Empty search results return 200 with empty array
|
||||
- Whitespace-only search param is ignored
|
||||
- Authentication required for read endpoints (401 without session)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Test file:** `backend/__tests__/archer-template-library.integration.test.js`
|
||||
|
||||
- Audit log entries created for create/update/delete/clone
|
||||
- Audit log failure does not block template operation
|
||||
- Failed operations do not produce audit entries
|
||||
- Full workflow: create → list → update → clone → delete
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
- TemplateSelector copy-to-clipboard behavior
|
||||
- TemplateFormModal validation prevents submission with empty required fields
|
||||
- Viewer role sees no action buttons
|
||||
- Template list grouped by vendor/platform renders correctly
|
||||
143
.kiro/specs/archer-template-library/requirements.md
Normal file
143
.kiro/specs/archer-template-library/requirements.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The Archer Template Library adds a template management system to the Ivanti Queue's Archer Risk Acceptance workflow. Templates store the static and semi-static content sections of Archer Risk Acceptance forms, keyed by Vendor > Platform > Model hierarchy. When working an Archer queue item, users select a template and copy pre-filled section content into the external Archer application (https://egrcprod.corp.chartercom.com), eliminating repetitive manual entry of identical content across submissions for the same vendor/platform/model combination.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Dashboard**: The STEAM Security Dashboard application
|
||||
- **Template_Library**: The collection of stored Archer Risk Acceptance templates, organized by Vendor > Platform > Model hierarchy
|
||||
- **Template**: A single template record containing static and semi-static section content for a specific Vendor/Platform/Model combination
|
||||
- **Template_Manager**: The UI section where users create, edit, clone, and delete templates
|
||||
- **Template_Selector**: The UI component within the Archer queue workflow that allows users to pick a template and copy section content
|
||||
- **Template_API**: The backend REST endpoints for CRUD operations on templates
|
||||
- **Section**: A named content block within a template corresponding to an Archer form section (e.g., Environment Overview, Segmentation, Mitigating Controls)
|
||||
- **Static_Section**: A template section whose content is identical across all submissions for a given Vendor/Platform/Model (Environment Overview, Segmentation, Mitigating Controls)
|
||||
- **Semi_Static_Section**: A template section with default content that may need per-request editing (Additional Info/Background, Charter Network Banner, Data Classification, Charter Network, Additional Access List)
|
||||
- **Vendor**: The equipment manufacturer (e.g., Harmonic, Vecima, Adtran)
|
||||
- **Platform**: The product category under a vendor (e.g., vCMTS, RPD, OLT)
|
||||
- **Model**: The specific hardware or software version under a platform (e.g., 3.29.1, ERM3-2-2, 9504N)
|
||||
- **Archer_Form**: The external Archer Risk Acceptance form in the eGRC platform with sections for General Information, Issues, Justification, Remediation Plans, Related Items, Mitigating Controls, and Attachments
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Template Data Model
|
||||
|
||||
**User Story:** As an editor, I want templates to store content organized by Vendor/Platform/Model and form section, so that I can maintain reusable content for each device type I submit Archer exceptions for.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Template_API SHALL store each Template with a vendor field (1 to 100 characters), a platform field (1 to 100 characters), and a model field (1 to 100 characters)
|
||||
2. THE Template_API SHALL store each Template with named Section content blocks for: environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, and additional_access_list, each with a maximum length of 10,000 characters
|
||||
3. THE Template_API SHALL enforce uniqueness on the combination of vendor, platform, and model after trimming leading and trailing whitespace (case-insensitive)
|
||||
4. THE Template_API SHALL store a created_at timestamp, updated_at timestamp, and created_by user reference for each Template
|
||||
5. WHEN a Template is created without content for any section, THE Template_API SHALL store empty strings for those sections
|
||||
6. IF a Template creation or update request provides a vendor, platform, or model value that is empty or exceeds 100 characters, THEN THE Template_API SHALL reject the request with a 400 status code and an error message indicating which field failed validation
|
||||
|
||||
### Requirement 2: Template CRUD API
|
||||
|
||||
**User Story:** As an editor, I want to create, read, update, and delete templates through the API, so that I can manage my template library programmatically and through the UI.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a valid Template creation request is received with vendor, platform, model, and at least one section content field, THE Template_API SHALL create the Template and return the created record including its generated ID with a 201 status code
|
||||
2. WHEN a Template creation request contains a vendor/platform/model combination that already exists (case-insensitive after trimming), THE Template_API SHALL return a 409 status code with an error message identifying the conflicting combination
|
||||
3. WHEN a Template creation request is missing vendor, platform, or model, THE Template_API SHALL return a 400 status code with an error message identifying the missing fields
|
||||
4. WHEN a GET request is made to the templates list endpoint, THE Template_API SHALL return all templates ordered by vendor, platform, and model ascending
|
||||
5. WHEN a GET request includes a vendor query parameter, THE Template_API SHALL filter results to templates matching that vendor (case-insensitive)
|
||||
6. WHEN a GET request includes a platform query parameter, THE Template_API SHALL filter results to templates matching that platform (case-insensitive)
|
||||
7. WHEN a valid Template update request is received, THE Template_API SHALL update only the specified fields and set the updated_at timestamp to the current time
|
||||
8. WHEN a Template update request changes vendor, platform, or model to a combination that already exists in another Template, THE Template_API SHALL return a 409 status code
|
||||
9. WHEN a Template delete request is received for an existing Template, THE Template_API SHALL remove the Template record and return a 200 status code
|
||||
10. THE Template_API SHALL require an authenticated session with editor or admin role for all write operations (create, update, delete, clone)
|
||||
11. THE Template_API SHALL require an authenticated session for all read operations
|
||||
12. IF a Template ID referenced in a GET, PUT, or DELETE request does not exist, THEN THE Template_API SHALL return a 404 status code with an error message indicating the Template was not found
|
||||
|
||||
### Requirement 3: Template Cloning
|
||||
|
||||
**User Story:** As an editor, I want to clone an existing template to create a new one for a similar device, so that I can reuse shared content between models on the same platform without retyping it.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a clone request is received for an existing Template, THE Template_API SHALL create a new Template that copies all section content from the source Template and return the newly created Template including its generated ID
|
||||
2. WHEN cloning a Template, THE Template_API SHALL require the caller to supply vendor, platform, and model values where at least one value differs from the source Template, such that the resulting combination is unique across all Templates in the system
|
||||
3. WHEN cloning a Template, THE Template_API SHALL set created_at to the current time and created_by to the requesting user
|
||||
4. IF the source Template ID does not exist, THEN THE Template_API SHALL return a 404 status code with an error message indicating the source Template was not found
|
||||
5. IF the supplied vendor, platform, and model combination already exists in another Template, THEN THE Template_API SHALL reject the clone request with a 409 status code and an error message indicating the combination is not unique
|
||||
|
||||
### Requirement 4: Template Manager UI
|
||||
|
||||
**User Story:** As an editor, I want a dedicated Template Manager section in the dashboard, so that I can browse, create, edit, and delete templates with a clear view of what is available.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Template_Manager SHALL display a list of all templates grouped by vendor, then by platform
|
||||
2. THE Template_Manager SHALL display the vendor, platform, and model for each template in the list
|
||||
3. WHEN the user clicks a create button, THE Template_Manager SHALL display a form with fields for vendor (max 100 characters), platform (max 100 characters), model (max 100 characters), and all eight section content areas defined in the Template data model
|
||||
4. WHEN the user clicks an edit button on a template, THE Template_Manager SHALL display the template content pre-populated in an editable form with the same fields as the create form
|
||||
5. WHEN the user clicks a delete button on a template, THE Template_Manager SHALL display a confirmation dialog identifying the template by vendor, platform, and model before deleting
|
||||
6. WHEN the user confirms the delete action in the confirmation dialog, THE Template_Manager SHALL delete the template via the API and remove it from the displayed list
|
||||
7. IF the user cancels the delete confirmation dialog, THEN THE Template_Manager SHALL dismiss the dialog and leave the template unchanged
|
||||
8. THE Template_Manager SHALL display section content fields as multi-line text areas with labels matching the Archer form section names
|
||||
9. WHEN the user submits the create or edit form with vendor, platform, or model fields empty, THE Template_Manager SHALL prevent submission and indicate which required fields are missing
|
||||
10. WHEN the user submits the create or edit form with a duplicate vendor/platform/model combination, THE Template_Manager SHALL display the conflict error returned by the API
|
||||
11. WHEN a create, edit, or delete operation succeeds, THE Template_Manager SHALL refresh the template list to reflect the change
|
||||
12. THE Template_Manager SHALL provide a clone button for each template that opens the create form pre-filled with the source template section content and empty vendor/platform/model fields
|
||||
13. WHILE the user has viewer role only, THE Template_Manager SHALL hide create, edit, delete, and clone buttons
|
||||
|
||||
### Requirement 5: Template Selection in Archer Queue Workflow
|
||||
|
||||
**User Story:** As an editor, I want to select a template when working an Archer queue item, so that I can quickly access pre-filled content to paste into the Archer application.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user is viewing an Archer workflow queue item, THE Template_Selector SHALL display a searchable dropdown that lists all available templates by their vendor/platform/model label
|
||||
2. WHEN the user types in the template picker search field, THE Template_Selector SHALL filter the template list to show only templates whose vendor, platform, or model contains the typed text as a case-insensitive substring
|
||||
3. WHEN the user selects a template from the dropdown, THE Template_Selector SHALL display all populated sections of the selected template in a panel, with Static_Sections (Environment Overview, Segmentation, Mitigating Controls) displayed first, followed by Semi_Static_Sections
|
||||
4. THE Template_Selector SHALL display each section with a human-readable label matching the Archer form section name (e.g., "Environment Overview" not "environment_overview")
|
||||
5. THE Template_Selector SHALL display a copy-to-clipboard button adjacent to each section content block
|
||||
6. WHEN the user clicks a copy button, THE Template_Selector SHALL copy that section's full text content to the system clipboard using the Clipboard API
|
||||
7. WHEN a section is copied successfully, THE Template_Selector SHALL display a brief visual confirmation (checkmark icon or "Copied!" text) for 2 seconds before reverting to the copy button state
|
||||
8. WHEN a template has an empty section, THE Template_Selector SHALL display that section with a muted italic placeholder text "No content stored" and disable the copy button for that section
|
||||
9. THE Template_Selector SHALL provide a "Copy All" button that concatenates all non-empty sections with section headers and copies the combined text to the clipboard
|
||||
|
||||
### Requirement 6: Template Search and Filtering API
|
||||
|
||||
**User Story:** As an editor, I want to search templates by vendor, platform, or model, so that I can quickly find the right template when working a queue item.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a search query parameter of at least 1 non-whitespace character is provided, THE Template_API SHALL return templates where the vendor, platform, or model contains the search string as a substring (case-insensitive partial match)
|
||||
2. WHEN vendor, platform, or model filter parameters are provided, THE Template_API SHALL return only templates whose corresponding field exactly matches the filter value (case-insensitive exact match), supporting any combination of these filters simultaneously
|
||||
3. WHEN both a search query parameter and field-specific filter parameters are provided, THE Template_API SHALL return only templates that satisfy both the search substring match AND all field-specific exact match filters (AND logic)
|
||||
4. THE Template_API SHALL return search and filter results ordered by vendor, platform, and model ascending
|
||||
5. WHEN no templates match the search or filter criteria, THE Template_API SHALL return an empty array with a 200 status code
|
||||
6. IF a search query parameter is provided but contains only whitespace or is empty, THEN THE Template_API SHALL ignore the search parameter and return results as if no search was specified
|
||||
|
||||
### Requirement 7: Distinct Values API for Hierarchy Navigation
|
||||
|
||||
**User Story:** As an editor, I want to browse the template hierarchy by vendor and platform, so that I can navigate to templates without knowing the exact model identifier.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a request is made to the vendors endpoint, THE Template_API SHALL return an array of distinct vendor names from all templates sorted alphabetically ascending
|
||||
2. WHEN a request is made to the platforms endpoint with a vendor parameter, THE Template_API SHALL return an array of distinct platform names for templates matching that vendor (case-insensitive) sorted alphabetically ascending
|
||||
3. WHEN a request is made to the models endpoint with vendor and platform parameters, THE Template_API SHALL return an array of distinct model names for templates matching that vendor and platform combination (case-insensitive) sorted alphabetically ascending
|
||||
4. IF the platforms endpoint is called without a vendor parameter, THEN THE Template_API SHALL return a 400 status code with an error message indicating vendor is required
|
||||
5. IF the models endpoint is called without both vendor and platform parameters, THEN THE Template_API SHALL return a 400 status code with an error message indicating which parameters are missing
|
||||
6. WHEN no templates match the provided parameters, THE Template_API SHALL return an empty array with a 200 status code
|
||||
7. THE Template_API SHALL require an authenticated session for all hierarchy endpoints
|
||||
|
||||
### Requirement 8: Audit Logging for Template Operations
|
||||
|
||||
**User Story:** As an admin, I want all template create, update, delete, and clone operations logged, so that I can track who modified template content and when.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a Template is created successfully, THE Template_API SHALL log an audit entry with action "template_created", the template ID, the requesting user's ID and username, and the requester's IP address
|
||||
2. WHEN a Template is updated successfully, THE Template_API SHALL log an audit entry with action "template_updated", the template ID, the requesting user's ID and username, the requester's IP address, and the list of field names that changed
|
||||
3. WHEN a Template is deleted successfully, THE Template_API SHALL log an audit entry with action "template_deleted", the template ID, the requesting user's ID and username, the requester's IP address, and the vendor/platform/model of the deleted template
|
||||
4. WHEN a Template is cloned successfully, THE Template_API SHALL log an audit entry with action "template_cloned", both the source and new template IDs, the requesting user's ID and username, and the requester's IP address
|
||||
5. IF an audit log entry fails to persist, THEN THE Template_API SHALL log the failure to the application error log and SHALL NOT block or fail the original template operation
|
||||
6. IF a template create, update, delete, or clone operation fails, THEN THE Template_API SHALL NOT log an audit entry for that operation
|
||||
242
.kiro/specs/archer-template-library/tasks.md
Normal file
242
.kiro/specs/archer-template-library/tasks.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Implementation Plan: Archer Template Library
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements the Archer Template Library feature — a template management system for storing and reusing Archer Risk Acceptance form content organized by Vendor > Platform > Model hierarchy. The implementation progresses from database schema, through backend CRUD/search API, to frontend Template Manager page and TemplateSelector integration in the Ivanti Todo Queue workflow.
|
||||
|
||||
The backend uses Node.js/Express 5 with PostgreSQL (pool from `backend/db.js`), following the existing factory-pattern route module convention. The frontend uses React 19 (plain JavaScript) with the project's dark theme aesthetic. Property-based tests use `fast-check` in `backend/__tests__/`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Database migration and schema setup
|
||||
- [x] 1.1 Create the `archer_templates` table migration
|
||||
- Create `backend/migrations/add_archer_templates_table.js` using the idempotent migration pattern (IF NOT EXISTS)
|
||||
- Define the `archer_templates` table with columns: `id SERIAL PRIMARY KEY`, `vendor VARCHAR(100) NOT NULL`, `platform VARCHAR(100) NOT NULL`, `model VARCHAR(100) NOT NULL`, 8 TEXT section columns (all `NOT NULL DEFAULT ''`), `created_by INTEGER REFERENCES users(id) ON DELETE SET NULL`, `created_at TIMESTAMPTZ DEFAULT NOW()`, `updated_at TIMESTAMPTZ DEFAULT NOW()`
|
||||
- Create unique index `idx_archer_templates_unique_combo` on `(LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))`
|
||||
- Create index `idx_archer_templates_vendor` on `(vendor)` and `idx_archer_templates_platform` on `(platform)` for list query performance
|
||||
- Register the migration in `backend/migrations/run-all.js` by appending `'add_archer_templates_table.js'` to the `POSTGRES_MIGRATIONS` array
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
|
||||
- [x] 2. Backend route module — core CRUD
|
||||
- [x] 2.1 Create `backend/routes/archerTemplates.js` with factory function and create endpoint
|
||||
- Create the file following the factory pattern: `function createArcherTemplatesRouter()` returning an Express Router
|
||||
- Import `pool` from `'../db'`, `{ requireAuth, requireGroup }` from `'../middleware/auth'`, and `logAudit` from `'../helpers/auditLog'`
|
||||
- Implement `POST /` — validate vendor/platform/model (required, 1-100 chars after trim, non-empty after trim), validate section fields (max 10,000 chars each, default to empty string), INSERT with RETURNING *, handle unique constraint violation (23505) as 409, call `logAudit` fire-and-forget on success with action `template_created`
|
||||
- Implement `GET /` — query all templates with optional search (ILIKE across vendor/platform/model), optional exact-match filters on vendor/platform/model (case-insensitive via LOWER/TRIM), combine with AND logic, ORDER BY vendor, platform, model ASC
|
||||
- Implement `GET /:id` — fetch single template by ID, return 404 if not found
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.10, 2.11, 2.12, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 8.1_
|
||||
|
||||
- [x] 2.2 Implement update, delete, and clone endpoints
|
||||
- Implement `PUT /:id` — validate provided fields (same rules as create), build dynamic UPDATE SET clause for only provided fields, always set `updated_at = NOW()`, check uniqueness if vendor/platform/model changed (excluding self), return 404/409/400 as appropriate, call `logAudit` with action `template_updated` and changed field names
|
||||
- Implement `DELETE /:id` — verify template exists (404 if not), DELETE, call `logAudit` with action `template_deleted` and vendor/platform/model of the deleted template, return 200
|
||||
- Implement `POST /:id/clone` — verify source exists (404 if not), validate new vendor/platform/model (required, same rules as create), INSERT copying all 8 section fields from source with new hierarchy values and `created_by = req.user.id`, handle uniqueness violation as 409, call `logAudit` with action `template_cloned` and both source/new IDs
|
||||
- _Requirements: 2.7, 2.8, 2.9, 2.10, 2.12, 3.1, 3.2, 3.3, 3.4, 3.5, 8.2, 8.3, 8.4, 8.5, 8.6_
|
||||
|
||||
- [x] 2.3 Implement hierarchy endpoints (vendors, platforms, models)
|
||||
- Implement `GET /hierarchy/vendors` — SELECT DISTINCT vendor ORDER BY vendor ASC
|
||||
- Implement `GET /hierarchy/platforms` — require `vendor` query param (400 if missing), SELECT DISTINCT platform WHERE LOWER(TRIM(vendor)) matches, ORDER BY platform ASC
|
||||
- Implement `GET /hierarchy/models` — require `vendor` and `platform` query params (400 if either missing), SELECT DISTINCT model WHERE both match, ORDER BY model ASC
|
||||
- All hierarchy endpoints require `requireAuth()` middleware
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
|
||||
|
||||
- [x] 2.4 Wire the router into `backend/server.js`
|
||||
- Add `const createArcherTemplatesRouter = require('./routes/archerTemplates');` to the imports section
|
||||
- Mount with `app.use('/api/archer-templates', createArcherTemplatesRouter());` after the existing archer-tickets route
|
||||
- _Requirements: 2.10, 2.11_
|
||||
|
||||
- [x] 3. Checkpoint - Backend API verification
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 4. Backend property-based tests
|
||||
- [ ]* 4.1 Write property test for template data round-trip preservation
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 1: Template data round-trip preservation**
|
||||
- Generate valid templates with random vendor (1-100 chars), platform (1-100 chars), model (1-100 chars), and section content (0-10,000 chars each); create via POST then GET by ID; assert all fields match (vendor/platform/model trimmed)
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 1.1, 1.2, 1.5, 2.1**
|
||||
|
||||
- [ ]* 4.2 Write property test for uniqueness enforcement
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 2: Uniqueness enforcement across all write operations**
|
||||
- Generate template pairs with matching LOWER(TRIM(vendor/platform/model)) but varying case/whitespace; assert second create returns 409, update to matching combo returns 409, clone to matching combo returns 409
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 1.3, 2.2, 2.8, 3.2, 3.5**
|
||||
|
||||
- [ ]* 4.3 Write property test for input validation
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 3: Input validation rejects invalid hierarchy fields**
|
||||
- Generate invalid inputs: empty strings, whitespace-only, strings > 100 chars for vendor/platform/model; assert POST and PUT return 400 with error identifying which field(s) failed
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 1.6, 2.3**
|
||||
|
||||
- [ ]* 4.4 Write property test for sorted results
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 4: List and search results are sorted**
|
||||
- Generate multiple templates with random hierarchy values, insert all, then GET with various search/filter combos; assert response is sorted by vendor ASC, platform ASC, model ASC (case-insensitive)
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.4, 6.4**
|
||||
|
||||
- [ ]* 4.5 Write property test for search and filter AND logic
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 5: Search and filter semantics with AND logic**
|
||||
- Generate template sets with overlapping vendor/platform/model values; apply combined search substring + field exact-match filters; assert every result satisfies ALL conditions and no matching template is missing
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.5, 2.6, 6.1, 6.2, 6.3**
|
||||
|
||||
- [ ]* 4.6 Write property test for partial update preservation
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 6: Partial update preserves unspecified fields**
|
||||
- Generate a template, then PUT with a random subset of fields; assert unmodified fields remain unchanged and `updated_at` is updated
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.7**
|
||||
|
||||
- [ ]* 4.7 Write property test for delete permanence
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 7: Delete removes template permanently**
|
||||
- Generate a template, DELETE it, then GET by ID; assert 404 response
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.9**
|
||||
|
||||
- [ ]* 4.8 Write property test for access control
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 8: Write operations require editor or admin role**
|
||||
- Generate write operation requests (POST/PUT/DELETE/clone) with viewer-role or unauthenticated sessions; assert all are rejected with 401 or 403
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.10, 4.13**
|
||||
|
||||
- [ ]* 4.9 Write property test for non-existent ID handling
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 9: Non-existent template ID returns 404**
|
||||
- Generate random IDs that don't exist; assert GET, PUT, DELETE, and clone all return 404
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 2.12, 3.4**
|
||||
|
||||
- [ ]* 4.10 Write property test for clone content preservation
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 10: Clone preserves all section content**
|
||||
- Generate a template with random section content, clone with new hierarchy values; assert all 8 section fields are byte-for-byte identical, ID differs, `created_at` is new, `created_by` matches requesting user
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 3.1, 3.3**
|
||||
|
||||
- [ ]* 4.11 Write property test for hierarchy distinct values
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 11: Hierarchy endpoints return distinct sorted values**
|
||||
- Generate templates with overlapping vendors/platforms/models; assert vendors endpoint returns deduplicated sorted array, platforms for a vendor returns only that vendor's platforms sorted, models for vendor+platform returns only matching models sorted
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 7.1, 7.2, 7.3**
|
||||
|
||||
- [ ]* 4.12 Write property test for Copy All concatenation format
|
||||
- File: `backend/__tests__/archer-template-library.property.test.js`
|
||||
- **Property 12: Copy All concatenation format**
|
||||
- Generate templates with random mix of populated and empty sections; construct the expected "Copy All" output (only non-empty sections, with headers, static first then semi-static); assert the concatenation matches expected format and order
|
||||
- Minimum 100 iterations
|
||||
- **Validates: Requirements 5.9**
|
||||
|
||||
- [x] 5. Checkpoint - Backend tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. Frontend Template Manager page
|
||||
- [x] 6.1 Create `frontend/src/components/pages/ArcherTemplatePage.js` with list view
|
||||
- Create the page component following the project's dark-theme inline-styles pattern
|
||||
- Implement template list fetch on mount from `GET /api/archer-templates`
|
||||
- Display templates grouped by vendor, then platform, with collapsible vendor groups
|
||||
- Show vendor, platform, and model for each template row
|
||||
- Conditionally render create/edit/clone/delete buttons using `useAuth()` role check (hide for viewer role)
|
||||
- _Requirements: 4.1, 4.2, 4.13_
|
||||
|
||||
- [x] 6.2 Implement TemplateFormModal (create/edit/clone)
|
||||
- Create a modal component (inline in ArcherTemplatePage.js or as a separate subcomponent)
|
||||
- Include fields for vendor (max 100 chars), platform (max 100 chars), model (max 100 chars), and all 8 section content textareas with human-readable labels
|
||||
- For create mode: all fields empty; for edit mode: pre-populate from template; for clone mode: pre-populate sections from source, leave hierarchy fields empty
|
||||
- Client-side validation: prevent submission with empty vendor/platform/model, show which required fields are missing
|
||||
- On submit: POST for create/clone, PUT for edit; handle 409 conflict errors by displaying the conflict message from API
|
||||
- On success: close modal and refresh template list
|
||||
- _Requirements: 4.3, 4.4, 4.8, 4.9, 4.10, 4.11, 4.12_
|
||||
|
||||
- [x] 6.3 Implement DeleteConfirmModal and delete flow
|
||||
- Create a confirmation dialog that identifies the template by vendor/platform/model
|
||||
- On confirm: call DELETE API, remove template from displayed list, close dialog
|
||||
- On cancel: dismiss dialog, leave template unchanged
|
||||
- _Requirements: 4.5, 4.6, 4.7_
|
||||
|
||||
- [x] 6.4 Wire ArcherTemplatePage into navigation
|
||||
- Add a navigation entry in `NavDrawer.js` for the Template Manager page
|
||||
- Add the page routing in `App.js` (following the existing page-switching pattern)
|
||||
- _Requirements: 4.1_
|
||||
|
||||
- [x] 7. Frontend TemplateSelector component
|
||||
- [x] 7.1 Create `frontend/src/components/TemplateSelector.js` with searchable dropdown
|
||||
- Create the reusable component that fetches templates from `GET /api/archer-templates`
|
||||
- Implement search input that filters the template list (case-insensitive substring match on vendor/platform/model — client-side filter for responsiveness)
|
||||
- Display templates in dropdown as `Vendor / Platform / Model` label
|
||||
- _Requirements: 5.1, 5.2_
|
||||
|
||||
- [x] 7.2 Implement section display panel and copy functionality
|
||||
- When a template is selected, display all populated sections in a panel
|
||||
- Order: static sections first (Environment Overview, Segmentation, Mitigating Controls), then semi-static sections (Additional Info/Background, Charter Network Banner, Data Classification, Charter Network, Additional Access List)
|
||||
- Display each section with human-readable label (not DB column name)
|
||||
- Show copy-to-clipboard button adjacent to each section; disable for empty sections (show muted "No content stored" placeholder)
|
||||
- On copy: use `navigator.clipboard.writeText()`, show checkmark/"Copied!" for 2 seconds then revert
|
||||
- Implement "Copy All" button: concatenate all non-empty sections with section headers, copy combined text
|
||||
- _Requirements: 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9_
|
||||
|
||||
- [x] 7.3 Integrate TemplateSelector into IvantiTodoQueuePage
|
||||
- Embed the TemplateSelector component in `IvantiTodoQueuePage.js` when viewing an Archer workflow queue item
|
||||
- Display as an expandable panel within the Archer queue item view
|
||||
- _Requirements: 5.1_
|
||||
|
||||
- [x] 8. Checkpoint - Frontend build and integration verification
|
||||
- Run `cd frontend && npm run build` to verify no ESLint or build errors
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 9. Backend unit and integration tests
|
||||
- [ ]* 9.1 Write unit tests for template CRUD operations
|
||||
- File: `backend/__tests__/archer-template-library.test.js`
|
||||
- Test template creation with all sections populated returns 201 with full record
|
||||
- Test template creation with no sections defaults to empty strings
|
||||
- Test timestamp metadata correctness (created_at, updated_at, created_by set properly)
|
||||
- Test clone metadata (new created_at, new created_by, different ID)
|
||||
- Test hierarchy endpoint without required params returns 400
|
||||
- Test empty search results return 200 with empty array
|
||||
- Test whitespace-only search param is ignored (returns all templates)
|
||||
- Test authentication required for read endpoints (401 without session)
|
||||
- _Requirements: 1.1, 1.2, 1.4, 1.5, 2.1, 2.3, 2.4, 2.11, 2.12, 3.1, 3.3, 6.5, 6.6, 7.4, 7.5, 7.6_
|
||||
|
||||
- [ ]* 9.2 Write integration tests for audit logging and full workflow
|
||||
- File: `backend/__tests__/archer-template-library.integration.test.js`
|
||||
- Test audit log entries created for create/update/delete/clone operations
|
||||
- Test audit log failure does not block template operation (mock logAudit to throw)
|
||||
- Test failed operations (400, 409, 404) do not produce audit entries
|
||||
- Test full workflow: create → list → update → clone → delete
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_
|
||||
|
||||
- [x] 10. Final checkpoint - Full test suite passes
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional test sub-tasks and can be skipped for a faster MVP.
|
||||
- Each task references specific requirements for traceability.
|
||||
- Property tests use `fast-check` with a minimum of 100 iterations and are tagged per the design's Testing Strategy.
|
||||
- The migration must be registered in `run-all.js` to be picked up by CI/CD deploys.
|
||||
- The route module follows the existing `createArcherTicketsRouter()` pattern in `routes/archerTickets.js`.
|
||||
- Frontend styling follows the dark-theme tactical intelligence aesthetic defined in `DESIGN_SYSTEM.md`.
|
||||
- Checkpoints (tasks 3, 5, 8, 10) ensure incremental validation at each layer.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["2.1"] },
|
||||
{ "id": 2, "tasks": ["2.2", "2.3"] },
|
||||
{ "id": 3, "tasks": ["2.4"] },
|
||||
{ "id": 4, "tasks": ["4.1", "4.2", "4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12"] },
|
||||
{ "id": 5, "tasks": ["6.1", "7.1"] },
|
||||
{ "id": 6, "tasks": ["6.2", "6.3", "7.2"] },
|
||||
{ "id": 7, "tasks": ["6.4", "7.3"] },
|
||||
{ "id": 8, "tasks": ["9.1", "9.2"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/ccp-metrics-view-restructure/.config.kiro
Normal file
1
.kiro/specs/ccp-metrics-view-restructure/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "54d073e2-9532-42f1-9315-f2129a2158af", "workflowType": "fast-task", "specType": "feature"}
|
||||
413
.kiro/specs/ccp-metrics-view-restructure/design.md
Normal file
413
.kiro/specs/ccp-metrics-view-restructure/design.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
This design restructures the CCP Metrics page from a vertical-first drill-down model to a metric-first model. The overview table changes from one row per vertical to one row per metric (aggregated across all verticals). Two new backend endpoints support the metric-centric view. The "By Vertical" table in the AggregatedBurndownChart is removed. Existing vertical-first endpoints are preserved for backward compatibility.
|
||||
|
||||
## Architecture
|
||||
|
||||
The restructure touches two layers:
|
||||
|
||||
1. **Backend** — Two new Express route handlers added to `backend/routes/vclMultiVertical.js`, querying `vcl_multi_vertical_summary` with aggregation across verticals.
|
||||
2. **Frontend** — Replace `VerticalTable` and `VerticalDetailView` components with `MetricTable` and `MetricDetailView` in `frontend/src/components/pages/CCPMetricsPage.js`. Remove the "By Vertical" table JSX from `AggregatedBurndownChart`. Adjust drill-down state from `(selectedVertical, selectedMetric, selectedTeam)` to `(selectedMetric, selectedVertical, selectedTeam)`.
|
||||
|
||||
No database schema changes are required. No new tables or columns are needed — the existing `vcl_multi_vertical_summary` table already contains all data needed for metric-centric aggregation.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: New Endpoints
|
||||
|
||||
#### GET /api/compliance/vcl-multi/metrics
|
||||
|
||||
Aggregates all metrics across verticals using only `ALL:` rollup rows from the latest upload per vertical.
|
||||
|
||||
```javascript
|
||||
// Query: get latest upload ID per vertical
|
||||
const { rows: latestUploads } = await pool.query(`
|
||||
SELECT DISTINCT ON (vertical) id, vertical
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY vertical, id DESC
|
||||
`);
|
||||
|
||||
const latestUploadIds = latestUploads.map(u => u.id);
|
||||
|
||||
// Aggregate metrics across verticals (ALL: rows only)
|
||||
const { rows: metrics } = await pool.query(`
|
||||
SELECT metric_id,
|
||||
MAX(metric_desc) AS metric_desc,
|
||||
MAX(category) AS category,
|
||||
SUM(non_compliant)::int AS non_compliant,
|
||||
SUM(compliant)::int AS compliant,
|
||||
SUM(total)::int AS total,
|
||||
ROUND(AVG(target::numeric), 4) AS target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
||||
GROUP BY metric_id
|
||||
ORDER BY non_compliant DESC
|
||||
`, [latestUploadIds]);
|
||||
|
||||
// Compute compliance_pct for each metric
|
||||
const result = metrics.map(m => ({
|
||||
...m,
|
||||
compliance_pct: m.total > 0 ? m.compliant / m.total : 0,
|
||||
}));
|
||||
```
|
||||
|
||||
**Response shape:**
|
||||
```json
|
||||
{
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "VM-001",
|
||||
"metric_desc": "Vulnerability Management - Patching",
|
||||
"category": "Vulnerability Management",
|
||||
"non_compliant": 450,
|
||||
"compliant": 3200,
|
||||
"total": 3650,
|
||||
"compliance_pct": 0.8767,
|
||||
"target": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/compliance/vcl-multi/metric/:id/verticals
|
||||
|
||||
Returns per-vertical breakdown for a specific metric, including sub-team data within each vertical.
|
||||
|
||||
```javascript
|
||||
const metricId = req.params.id;
|
||||
if (!metricId || metricId.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric ID' });
|
||||
}
|
||||
|
||||
// Get latest upload per vertical
|
||||
const { rows: latestUploads } = await pool.query(`
|
||||
SELECT DISTINCT ON (vertical) id, vertical
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY vertical, id DESC
|
||||
`);
|
||||
const latestUploadIds = latestUploads.map(u => u.id);
|
||||
|
||||
// Get all rows for this metric from latest uploads
|
||||
const { rows: allRows } = await pool.query(`
|
||||
SELECT vertical, metric_desc, category, team,
|
||||
non_compliant, compliant, total, compliance_pct, target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND metric_id = $2
|
||||
ORDER BY vertical, team
|
||||
`, [latestUploadIds, metricId]);
|
||||
|
||||
// Separate rollup rows (ALL:) from sub-team rows
|
||||
// Build per-vertical entries with nested sub_teams
|
||||
```
|
||||
|
||||
**Response shape:**
|
||||
```json
|
||||
{
|
||||
"metric_id": "VM-001",
|
||||
"metric_desc": "Vulnerability Management - Patching",
|
||||
"category": "Vulnerability Management",
|
||||
"verticals": [
|
||||
{
|
||||
"vertical": "NTS_AEO",
|
||||
"non_compliant": 200,
|
||||
"compliant": 1800,
|
||||
"total": 2000,
|
||||
"compliance_pct": 0.90,
|
||||
"target": 0.95,
|
||||
"sub_teams": [
|
||||
{
|
||||
"team": "STEAM",
|
||||
"non_compliant": 120,
|
||||
"compliant": 1080,
|
||||
"total": 1200,
|
||||
"compliance_pct": 0.90
|
||||
},
|
||||
{
|
||||
"team": "ACCESS-ENG",
|
||||
"non_compliant": 80,
|
||||
"compliant": 720,
|
||||
"total": 800,
|
||||
"compliance_pct": 0.90
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend: Component Changes
|
||||
|
||||
#### AggregatedBurndownChart (Modified)
|
||||
|
||||
Remove the "By Vertical" contribution table JSX block. The component continues to receive `data.by_vertical` in its props (for backward compat with the `/burndown` API response) but no longer renders it.
|
||||
|
||||
**Before:** Summary header + bar chart + "By Vertical" table
|
||||
**After:** Summary header + bar chart only
|
||||
|
||||
#### MetricTable (New — replaces VerticalTable)
|
||||
|
||||
Renders one row per metric from the `/metrics` endpoint response. Columns: Metric ID, Description, Category, Compliant, Non-Compliant, Total, Compliance %, Target %. Rows are clickable — clicking triggers `onSelectMetric(metricId)`.
|
||||
|
||||
```javascript
|
||||
function MetricTable({ metrics, onSelectMetric }) {
|
||||
if (!metrics || metrics.length === 0) return null;
|
||||
// Render table with columns: metric_id, metric_desc, category,
|
||||
// compliant, non_compliant, total, compliance_pct, target
|
||||
// onClick row → onSelectMetric(metric.metric_id)
|
||||
}
|
||||
```
|
||||
|
||||
#### MetricDetailView (New — replaces VerticalDetailView)
|
||||
|
||||
Fetches data from `GET /metric/:id/verticals` and displays:
|
||||
- Header with metric ID, description, category
|
||||
- Aggregated stats cards (total, compliant, non-compliant, compliance %)
|
||||
- Table of verticals with per-vertical compliance numbers
|
||||
- Clicking a vertical row navigates to the sub-team view
|
||||
|
||||
```javascript
|
||||
function MetricDetailView({ metricId, onBack, onSelectVertical }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(metricId)}/verticals`,
|
||||
{ credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => { setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [metricId]);
|
||||
// Render: back button, header, stats, verticals table
|
||||
}
|
||||
```
|
||||
|
||||
#### MetricSubTeamView (Reused with minor adjustments)
|
||||
|
||||
The existing `MetricSubTeamView` component is reused. The only change is that it now receives its `metricData` (including `sub_teams`) from the `MetricDetailView`'s vertical entry rather than from the `VerticalDetailView`'s metric entry. The props interface remains the same: `{ vertical, metricId, metricData, onBack, onSelectTeam }`.
|
||||
|
||||
#### MetricDeviceList (Reused unchanged)
|
||||
|
||||
The existing `MetricDeviceList` component is reused without modification. It already accepts `{ vertical, metricId, team, onBack }` and calls the existing devices endpoint.
|
||||
|
||||
### Frontend: Drill-Down State Changes
|
||||
|
||||
**Current state model:**
|
||||
```
|
||||
selectedVertical → selectedMetric → selectedTeam
|
||||
```
|
||||
|
||||
**New state model:**
|
||||
```
|
||||
selectedMetric → selectedVertical → selectedTeam
|
||||
```
|
||||
|
||||
The main component's render logic changes from:
|
||||
|
||||
```javascript
|
||||
// OLD
|
||||
if (selectedTeam && selectedMetric && selectedVertical) → MetricDeviceList
|
||||
if (selectedMetric && selectedVertical) → MetricSubTeamView
|
||||
if (selectedVertical) → VerticalDetailView
|
||||
else → Overview (VerticalTable)
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```javascript
|
||||
// NEW
|
||||
if (selectedTeam !== null && selectedMetric && selectedVertical) → MetricDeviceList
|
||||
if (selectedVertical && selectedMetric) → MetricSubTeamView
|
||||
if (selectedMetric) → MetricDetailView
|
||||
else → Overview (MetricTable)
|
||||
```
|
||||
|
||||
**State variables:**
|
||||
- `selectedMetric` — string (metric_id) or null
|
||||
- `selectedMetricData` — object with metric context (metric_desc, category) or null
|
||||
- `selectedVertical` — string (vertical code) or null
|
||||
- `selectedVerticalData` — object with vertical's sub_teams for the selected metric or null
|
||||
- `selectedTeam` — string (team name) or null (null with selectedVertical set = "View All Devices")
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Overview │
|
||||
│ GET /stats → StatsBar, DonutChart, TrendChart │
|
||||
│ GET /burndown → AggregatedBurndownChart (no By Vertical table) │
|
||||
│ GET /metrics → MetricTable │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ click metric row
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MetricDetailView │
|
||||
│ GET /metric/:id/verticals → header + verticals table │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ click vertical row
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MetricSubTeamView │
|
||||
│ (data passed from parent — no additional fetch) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ click team row
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MetricDeviceList │
|
||||
│ GET /vertical/:code/metric/:metricId/devices?team=X │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
### New API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/compliance/vcl-multi/metrics` | requireAuth() | Metrics aggregated across all verticals |
|
||||
| GET | `/api/compliance/vcl-multi/metric/:id/verticals` | requireAuth() | Per-vertical breakdown for a metric |
|
||||
|
||||
### Preserved API Endpoints (Backward Compatibility)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/api/compliance/vcl-multi/stats` | Unchanged — still returns `vertical_breakdown` and `metric_breakdown` |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metrics` | Unchanged |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` | Unchanged |
|
||||
| GET | `/api/compliance/vcl-multi/vertical/:code/burndown` | Unchanged |
|
||||
| GET | `/api/compliance/vcl-multi/burndown` | Unchanged — still returns `by_vertical` in response |
|
||||
|
||||
### Component Props Interfaces
|
||||
|
||||
```javascript
|
||||
// MetricTable
|
||||
MetricTable.propTypes = {
|
||||
metrics: Array, // from GET /metrics response
|
||||
onSelectMetric: Function, // (metricId: string) => void
|
||||
};
|
||||
|
||||
// MetricDetailView
|
||||
MetricDetailView.propTypes = {
|
||||
metricId: String,
|
||||
onBack: Function, // () => void — returns to overview
|
||||
onSelectVertical: Function, // (vertical: string, verticalData: object) => void
|
||||
};
|
||||
|
||||
// MetricSubTeamView (existing — props unchanged)
|
||||
MetricSubTeamView.propTypes = {
|
||||
vertical: String,
|
||||
metricId: String,
|
||||
metricData: Object, // { metric_desc, sub_teams, target, ... }
|
||||
onBack: Function, // () => void — returns to metric-vertical view
|
||||
onSelectTeam: Function, // (team: string|null) => void
|
||||
};
|
||||
|
||||
// MetricDeviceList (existing — props unchanged)
|
||||
MetricDeviceList.propTypes = {
|
||||
vertical: String,
|
||||
metricId: String,
|
||||
team: String, // null = all teams
|
||||
onBack: Function, // () => void — returns to sub-team view
|
||||
};
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
No new database tables or columns. The feature uses existing data:
|
||||
|
||||
### vcl_multi_vertical_summary (existing)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | SERIAL | PK |
|
||||
| upload_id | INTEGER | FK → compliance_uploads |
|
||||
| vertical | TEXT | Vertical code (e.g., "NTS_AEO") |
|
||||
| metric_id | TEXT | Metric identifier (e.g., "VM-001") |
|
||||
| metric_desc | TEXT | Human-readable description |
|
||||
| category | TEXT | Metric category |
|
||||
| team | TEXT | "ALL: NTS-AEO" for rollup, "STEAM" for sub-team |
|
||||
| non_compliant | INTEGER | Count |
|
||||
| compliant | INTEGER | Count |
|
||||
| total | INTEGER | Count |
|
||||
| compliance_pct | NUMERIC(5,2) | Stored as decimal (0.95 = 95%) |
|
||||
| target | NUMERIC(5,2) | Stored as decimal |
|
||||
|
||||
**Key convention:** Rows where `team LIKE 'ALL:%'` are rollup rows that include all sub-team totals. Sub-team rows are individual team breakdowns. Only rollup rows should be used for aggregation to avoid double-counting.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend
|
||||
|
||||
| Scenario | Response |
|
||||
|---|---|
|
||||
| No auth cookie / expired session | 401 `{ "error": "Authentication required" }` |
|
||||
| Metric ID > 50 chars | 400 `{ "error": "Invalid metric ID" }` |
|
||||
| Database query failure | 500 `{ "error": "Database error" }` |
|
||||
| No data for metric | 200 with empty `verticals` array |
|
||||
| No summary data at all | 200 with empty `metrics` array |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Fetch failure on overview | Existing error state renders error message |
|
||||
| Fetch failure on MetricDetailView | Show error message in component |
|
||||
| Empty metrics array | Show "No metrics data" empty state |
|
||||
| Empty verticals array for a metric | Show "No verticals found for this metric" message |
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: Metrics aggregation uses only rollup rows from latest uploads
|
||||
|
||||
*For any* set of `vcl_multi_vertical_summary` rows across multiple verticals and uploads, the `GET /metrics` endpoint SHALL return exactly one entry per distinct `metric_id`, with `non_compliant`, `compliant`, and `total` equal to the sum of only `ALL:`-prefixed team rows from the single latest upload per vertical.
|
||||
|
||||
**Validates: Requirements 3.1, 3.3**
|
||||
|
||||
### Property 2: Metrics computed fields are mathematically correct
|
||||
|
||||
*For any* metric entry returned by the `GET /metrics` endpoint, `compliance_pct` SHALL equal `compliant / total` (or 0 when total is 0), and `target` SHALL equal the arithmetic mean of the `target` values across all verticals that have that metric in their latest upload.
|
||||
|
||||
**Validates: Requirements 3.4, 3.5**
|
||||
|
||||
### Property 3: Metrics response is sorted by non-compliant descending
|
||||
|
||||
*For any* response from `GET /metrics` containing two or more metric entries, each entry's `non_compliant` value SHALL be greater than or equal to the next entry's `non_compliant` value.
|
||||
|
||||
**Validates: Requirements 3.6**
|
||||
|
||||
### Property 4: Metric-verticals breakdown is complete and correct
|
||||
|
||||
*For any* metric ID that exists in the database, the `GET /metric/:id/verticals` endpoint SHALL return one vertical entry for each vertical that has that metric in its latest upload, with each entry's `sub_teams` array containing exactly the non-rollup, non-"(Other)" team rows for that metric in that vertical.
|
||||
|
||||
**Validates: Requirements 4.1, 4.3**
|
||||
|
||||
### Property 5: Metric-verticals response is sorted by non-compliant descending
|
||||
|
||||
*For any* response from `GET /metric/:id/verticals` containing two or more vertical entries, each entry's `non_compliant` value SHALL be greater than or equal to the next entry's `non_compliant` value.
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 6: Drill-down state determines rendered view
|
||||
|
||||
*For any* combination of `(selectedMetric, selectedVertical, selectedTeam)` state values, the CCP_Metrics_Page SHALL render exactly one view: Overview when no metric is selected, MetricDetailView when only metric is selected, MetricSubTeamView when metric and vertical are selected (team is null or absent), and MetricDeviceList when all three are selected.
|
||||
|
||||
**Validates: Requirements 6.1, 6.5**
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests (Backend)
|
||||
|
||||
Properties 1–5 are testable via property-based tests against the backend endpoints. The test setup generates random `vcl_multi_vertical_summary` rows with varying numbers of verticals, metrics, uploads, and team configurations, then asserts the invariants hold.
|
||||
|
||||
### Example-Based Tests (Frontend)
|
||||
|
||||
Requirements 1.x, 2.x, 5.x, and 6.2–6.4 are UI rendering and interaction tests best covered by example-based component tests using React Testing Library.
|
||||
|
||||
### Integration Tests (Backward Compatibility)
|
||||
|
||||
Requirements 7.x are integration tests that verify existing endpoints continue to return the same response shape after the new endpoints are added.
|
||||
112
.kiro/specs/ccp-metrics-view-restructure/requirements.md
Normal file
112
.kiro/specs/ccp-metrics-view-restructure/requirements.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature restructures the CCP Metrics page drill-down hierarchy from a vertical-first model (Vertical → Metric → Subteam → Devices) to a metric-first model (Metric → Vertical → Subteam → Devices). The overview table changes from showing one row per vertical to one row per metric (aggregated across all verticals), with each metric appearing once with totals summed across verticals. Clicking a metric drills into which verticals have that metric. Additionally, the "By Vertical" contribution table currently rendered below the AggregatedBurndownChart is removed — the burndown chart and summary stats remain.
|
||||
|
||||
Two new backend endpoints support the metric-centric view alongside existing vertical-first endpoints, which are preserved for backward compatibility.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **CCP_Metrics_Page**: The React page component (`CCPMetricsPage.js`) that displays cross-organizational compliance posture and provides drill-down navigation.
|
||||
- **Overview_Table**: The primary data table on the CCP_Metrics_Page overview that users interact with to begin drill-down navigation.
|
||||
- **Metrics_API**: The new backend endpoint `GET /api/compliance/vcl-multi/metrics` that returns all metrics aggregated across verticals.
|
||||
- **Metric_Verticals_API**: The new backend endpoint `GET /api/compliance/vcl-multi/metric/:id/verticals` that returns per-vertical breakdown for a specific metric.
|
||||
- **Devices_API**: The existing backend endpoint `GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` that returns non-compliant devices filtered by vertical, metric, and optionally team.
|
||||
- **Drill_Down_State**: The set of React state variables that track the user's current navigation depth within the metric-centric hierarchy.
|
||||
- **AggregatedBurndownChart**: The existing React component that displays the cross-vertical burndown forecast bar chart and summary statistics on the overview page.
|
||||
- **Metric_Row**: A single row in the Overview_Table representing one metric with compliance totals summed across all verticals.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Remove "By Vertical" Table from AggregatedBurndownChart
|
||||
|
||||
**User Story:** As a compliance analyst, I want the burndown chart section to show only the forecast chart and summary stats without the per-vertical contribution table, so that the overview page is less cluttered and the vertical breakdown is accessible through the drill-down instead.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE AggregatedBurndownChart component SHALL render the summary header (total non-compliant, blockers, in-progress, projected clear date) and the bar chart.
|
||||
2. THE AggregatedBurndownChart component SHALL omit the "By Vertical" contribution table that previously appeared below the bar chart.
|
||||
3. THE AggregatedBurndownChart component SHALL preserve all existing loading, error, empty-state, and all-blockers display behaviors.
|
||||
|
||||
### Requirement 2: Metric-Aggregated Overview Table
|
||||
|
||||
**User Story:** As a senior leader, I want the overview table to show one row per metric with totals summed across all verticals, so that I can immediately see which metrics have the most non-compliance across the organization.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Overview_Table SHALL display one Metric_Row per distinct metric, with compliance totals (compliant, non_compliant, total, compliance_pct, target) aggregated across all verticals.
|
||||
2. THE Overview_Table SHALL display columns for metric ID, description, category, compliant count, non-compliant count, total count, compliance percentage, and target percentage.
|
||||
3. THE Overview_Table SHALL sort Metric_Rows by non-compliant count in descending order by default.
|
||||
4. WHEN a user clicks a Metric_Row, THE CCP_Metrics_Page SHALL navigate to the metric-vertical drill-down view for that metric.
|
||||
5. THE Overview_Table SHALL replace the existing VerticalTable component on the overview page.
|
||||
|
||||
### Requirement 3: Metrics Aggregated API Endpoint
|
||||
|
||||
**User Story:** As a frontend developer, I want a single API endpoint that returns all metrics aggregated across verticals, so that the overview table can display metric-centric data without client-side aggregation.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/compliance/vcl-multi/metrics`, THE Metrics_API SHALL return a JSON response containing a `metrics` array where each entry represents one distinct metric aggregated across all verticals.
|
||||
2. THE Metrics_API SHALL compute each metric entry with fields: `metric_id`, `metric_desc`, `category`, `non_compliant`, `compliant`, `total`, `compliance_pct`, and `target`.
|
||||
3. THE Metrics_API SHALL aggregate totals by summing `non_compliant`, `compliant`, and `total` across all verticals for each metric, using only rollup rows (team starting with "ALL:") from the latest upload per vertical.
|
||||
4. THE Metrics_API SHALL compute `compliance_pct` as `compliant / total` for each aggregated metric.
|
||||
5. THE Metrics_API SHALL compute `target` as the average target across verticals for each metric.
|
||||
6. THE Metrics_API SHALL sort the returned metrics array by `non_compliant` in descending order.
|
||||
7. WHEN no summary data exists, THE Metrics_API SHALL return an empty `metrics` array.
|
||||
8. THE Metrics_API SHALL require authentication via `requireAuth()` middleware.
|
||||
9. IF a database error occurs, THEN THE Metrics_API SHALL return HTTP 500 with `{ "error": "Database error" }`.
|
||||
|
||||
### Requirement 4: Metric-Verticals Drill-Down API Endpoint
|
||||
|
||||
**User Story:** As a frontend developer, I want an API endpoint that returns which verticals have a specific metric and their per-vertical compliance numbers, so that the drill-down view can show vertical breakdown for a selected metric.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/compliance/vcl-multi/metric/:id/verticals`, THE Metric_Verticals_API SHALL return a JSON response containing a `verticals` array with per-vertical breakdown for the specified metric.
|
||||
2. THE Metric_Verticals_API SHALL return each vertical entry with fields: `vertical`, `non_compliant`, `compliant`, `total`, `compliance_pct`, `target`, and `sub_teams`.
|
||||
3. THE Metric_Verticals_API SHALL include a `sub_teams` array within each vertical entry containing per-team breakdown with fields: `team`, `non_compliant`, `compliant`, `total`, `compliance_pct`.
|
||||
4. THE Metric_Verticals_API SHALL sort the `verticals` array by `non_compliant` in descending order.
|
||||
5. THE Metric_Verticals_API SHALL also return `metric_id`, `metric_desc`, and `category` fields at the top level for display context.
|
||||
6. WHEN the specified metric ID does not exist in any vertical, THE Metric_Verticals_API SHALL return an empty `verticals` array with the metric_id echoed back.
|
||||
7. IF the metric ID parameter exceeds 50 characters, THEN THE Metric_Verticals_API SHALL return HTTP 400 with `{ "error": "Invalid metric ID" }`.
|
||||
8. THE Metric_Verticals_API SHALL require authentication via `requireAuth()` middleware.
|
||||
9. IF a database error occurs, THEN THE Metric_Verticals_API SHALL return HTTP 500 with `{ "error": "Database error" }`.
|
||||
|
||||
### Requirement 5: Metric-Centric Drill-Down Hierarchy
|
||||
|
||||
**User Story:** As a compliance analyst, I want to drill down from a metric to see which verticals are affected, then into a vertical's sub-teams, and finally to the device list, so that I can trace non-compliance from the metric level down to individual devices.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user clicks a Metric_Row in the Overview_Table, THE CCP_Metrics_Page SHALL display a metric-vertical view showing per-vertical breakdown for that metric.
|
||||
2. THE metric-vertical view SHALL display the metric ID, description, and category as a header, along with aggregated compliance stats for that metric.
|
||||
3. THE metric-vertical view SHALL display a table of verticals with columns: vertical name, compliant, non-compliant, total, and compliance percentage.
|
||||
4. WHEN a user clicks a vertical row in the metric-vertical view, THE CCP_Metrics_Page SHALL display a sub-team breakdown view for that vertical and metric combination.
|
||||
5. THE sub-team breakdown view SHALL display per-team compliance numbers with columns: team name, compliant, non-compliant, total, and compliance percentage.
|
||||
6. WHEN a user clicks a sub-team row in the sub-team view, THE CCP_Metrics_Page SHALL display the device list filtered by the selected vertical, metric, and team.
|
||||
7. THE device list view SHALL reuse the existing Devices_API endpoint (`GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices`) with the vertical and metric parameters derived from the drill-down context.
|
||||
8. WHEN a user clicks a "View All Devices" option in the sub-team view, THE CCP_Metrics_Page SHALL display the device list for all teams within that vertical and metric.
|
||||
|
||||
### Requirement 6: Drill-Down Navigation State
|
||||
|
||||
**User Story:** As a user navigating the drill-down hierarchy, I want clear back-navigation at each level so that I can return to the previous view without losing context.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Drill_Down_State SHALL track the selected metric, selected vertical, and selected team to determine which view to render.
|
||||
2. WHEN the metric-vertical view is displayed, THE CCP_Metrics_Page SHALL provide a "Back to Overview" navigation element that clears the selected metric and returns to the overview.
|
||||
3. WHEN the sub-team view is displayed, THE CCP_Metrics_Page SHALL provide a "Back to Verticals" navigation element that clears the selected vertical and returns to the metric-vertical view.
|
||||
4. WHEN the device list view is displayed, THE CCP_Metrics_Page SHALL provide a "Back to Sub-Teams" navigation element that clears the selected team and returns to the sub-team view.
|
||||
5. THE CCP_Metrics_Page SHALL render the overview (with Overview_Table) when no metric is selected in the Drill_Down_State.
|
||||
|
||||
### Requirement 7: Backward Compatibility
|
||||
|
||||
**User Story:** As a developer maintaining the system, I want existing vertical-first endpoints to remain functional so that any external consumers or future features relying on them continue to work.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE backend SHALL preserve the existing `GET /api/compliance/vcl-multi/vertical/:code/metrics` endpoint with unchanged behavior and response shape.
|
||||
2. THE backend SHALL preserve the existing `GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` endpoint with unchanged behavior and response shape.
|
||||
3. THE backend SHALL preserve the existing `GET /api/compliance/vcl-multi/vertical/:code/burndown` endpoint with unchanged behavior and response shape.
|
||||
4. THE backend SHALL preserve the existing `GET /api/compliance/vcl-multi/stats` endpoint with unchanged behavior and response shape, including the `vertical_breakdown` and `metric_breakdown` fields.
|
||||
151
.kiro/specs/ccp-metrics-view-restructure/tasks.md
Normal file
151
.kiro/specs/ccp-metrics-view-restructure/tasks.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Implementation Plan: CCP Metrics View Restructure
|
||||
|
||||
## Overview
|
||||
|
||||
Restructure the CCP Metrics page from a vertical-first drill-down model to a metric-first model. Two new backend endpoints aggregate metrics across verticals. The frontend replaces VerticalTable/VerticalDetailView with MetricTable/MetricDetailView, removes the "By Vertical" table from AggregatedBurndownChart, and inverts the drill-down state hierarchy.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add backend endpoints for metric-centric aggregation
|
||||
- [x] 1.1 Implement GET /metrics endpoint in backend/routes/vclMultiVertical.js
|
||||
- Add route handler that queries `vcl_multi_vertical_summary` using only `ALL:` rollup rows from the latest upload per vertical
|
||||
- Aggregate by `metric_id`: SUM non_compliant, compliant, total; MAX metric_desc, category; AVG target
|
||||
- Compute `compliance_pct` as `compliant / total` (0 when total is 0)
|
||||
- Sort by `non_compliant` DESC
|
||||
- Return `{ metrics: [...] }` with empty array when no data exists
|
||||
- Require `requireAuth()` middleware
|
||||
- Return HTTP 500 with `{ "error": "Database error" }` on query failure
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9_
|
||||
|
||||
- [x] 1.2 Implement GET /metric/:id/verticals endpoint in backend/routes/vclMultiVertical.js
|
||||
- Validate metric ID parameter (reject if > 50 chars with HTTP 400)
|
||||
- Query all rows for the metric from latest uploads
|
||||
- Separate `ALL:` rollup rows from sub-team rows
|
||||
- Build per-vertical entries with nested `sub_teams` arrays
|
||||
- Sort verticals by `non_compliant` DESC
|
||||
- Return `{ metric_id, metric_desc, category, verticals: [...] }` with empty verticals array when metric not found
|
||||
- Require `requireAuth()` middleware
|
||||
- Return HTTP 500 with `{ "error": "Database error" }` on query failure
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9_
|
||||
|
||||
- [ ]* 1.3 Write property test: Metrics aggregation uses only rollup rows from latest uploads
|
||||
- **Property 1: Metrics aggregation uses only rollup rows from latest uploads**
|
||||
- Generate random `vcl_multi_vertical_summary` rows with multiple verticals, metrics, uploads, and team configurations
|
||||
- Assert the endpoint returns exactly one entry per distinct `metric_id` with totals matching only `ALL:`-prefixed rows from the latest upload per vertical
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 3.1, 3.3**
|
||||
|
||||
- [ ]* 1.4 Write property test: Metrics computed fields are mathematically correct
|
||||
- **Property 2: Metrics computed fields are mathematically correct**
|
||||
- Assert `compliance_pct` equals `compliant / total` (or 0 when total is 0) for each metric
|
||||
- Assert `target` equals the arithmetic mean of target values across verticals for each metric
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 3.4, 3.5**
|
||||
|
||||
- [ ]* 1.5 Write property test: Metrics response is sorted by non-compliant descending
|
||||
- **Property 3: Metrics response is sorted by non-compliant descending**
|
||||
- Assert each entry's `non_compliant` is >= the next entry's `non_compliant`
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 3.6**
|
||||
|
||||
- [ ]* 1.6 Write property test: Metric-verticals breakdown is complete and correct
|
||||
- **Property 4: Metric-verticals breakdown is complete and correct**
|
||||
- Assert one vertical entry per vertical that has the metric in its latest upload
|
||||
- Assert each entry's `sub_teams` contains exactly the non-rollup team rows for that metric/vertical
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 4.1, 4.3**
|
||||
|
||||
- [ ]* 1.7 Write property test: Metric-verticals response is sorted by non-compliant descending
|
||||
- **Property 5: Metric-verticals response is sorted by non-compliant descending**
|
||||
- Assert each vertical entry's `non_compliant` is >= the next entry's `non_compliant`
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 4.4**
|
||||
|
||||
- [x] 2. Checkpoint - Backend endpoints verified
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Remove "By Vertical" table from AggregatedBurndownChart
|
||||
- [x] 3.1 Remove the "By Vertical" contribution table JSX from AggregatedBurndownChart in frontend/src/components/pages/CCPMetricsPage.js
|
||||
- Remove the entire `{data.by_vertical && data.by_vertical.length > 0 && (...)}` block that renders the per-vertical table below the bar chart
|
||||
- Preserve the summary header (total non-compliant, blockers, in-progress, projected clear date) and bar chart
|
||||
- Preserve all loading, error, empty-state, and all-blockers display behaviors
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [x] 4. Implement MetricTable component and replace VerticalTable
|
||||
- [x] 4.1 Create MetricTable component in frontend/src/components/pages/CCPMetricsPage.js
|
||||
- Render one row per metric from the `/metrics` endpoint response
|
||||
- Columns: Metric ID, Description, Category, Compliant, Non-Compliant, Total, Compliance %, Target %
|
||||
- Sort rows by non-compliant descending (server-side, already sorted)
|
||||
- Rows are clickable — clicking triggers `onSelectMetric(metricId)`
|
||||
- Handle empty state (no metrics data)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||
|
||||
- [x] 4.2 Replace VerticalTable usage with MetricTable in the overview render logic
|
||||
- Add fetch call to `GET /api/compliance/vcl-multi/metrics` in the overview data loading
|
||||
- Pass fetched metrics data to MetricTable
|
||||
- Wire `onSelectMetric` to set `selectedMetric` state
|
||||
- _Requirements: 2.4, 2.5_
|
||||
|
||||
- [x] 5. Implement MetricDetailView and update drill-down state
|
||||
- [x] 5.1 Create MetricDetailView component in frontend/src/components/pages/CCPMetricsPage.js
|
||||
- Fetch data from `GET /metric/:id/verticals` on mount
|
||||
- Display header with metric ID, description, category
|
||||
- Display aggregated stats cards (total, compliant, non-compliant, compliance %)
|
||||
- Display table of verticals with columns: vertical name, compliant, non-compliant, total, compliance %
|
||||
- Clicking a vertical row calls `onSelectVertical(vertical, verticalData)`
|
||||
- Include "Back to Overview" button that calls `onBack`
|
||||
- Handle loading, error, and empty states
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 6.2_
|
||||
|
||||
- [x] 5.2 Restructure drill-down state in CCPMetricsPage main component
|
||||
- Change state model from `selectedVertical → selectedMetric → selectedTeam` to `selectedMetric → selectedVertical → selectedTeam`
|
||||
- Add state variables: `selectedMetric`, `selectedMetricData`, `selectedVertical`, `selectedVerticalData`, `selectedTeam`
|
||||
- Update render logic: no metric = Overview (MetricTable), metric only = MetricDetailView, metric+vertical = MetricSubTeamView, all three = MetricDeviceList
|
||||
- Wire back-navigation handlers at each level (clear appropriate state to go up one level)
|
||||
- Pass `selectedVerticalData.sub_teams` to MetricSubTeamView as `metricData`
|
||||
- _Requirements: 5.4, 5.5, 5.6, 5.7, 5.8, 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [ ]* 5.3 Write property test: Drill-down state determines rendered view
|
||||
- **Property 6: Drill-down state determines rendered view**
|
||||
- Generate random combinations of `(selectedMetric, selectedVertical, selectedTeam)` state values
|
||||
- Assert exactly one view is rendered for each combination following the state hierarchy rules
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- **Validates: Requirements 6.1, 6.5**
|
||||
|
||||
- [ ] 6. Verify backward compatibility of existing endpoints
|
||||
- [ ]* 6.1 Write integration tests verifying existing endpoints are unchanged
|
||||
- Verify `GET /vertical/:code/metrics` returns same response shape
|
||||
- Verify `GET /vertical/:code/metric/:metricId/devices` returns same response shape
|
||||
- Verify `GET /vertical/:code/burndown` returns same response shape
|
||||
- Verify `GET /stats` still returns `vertical_breakdown` and `metric_breakdown` fields
|
||||
- Test file: `backend/__tests__/ccp-metrics-view-restructure.property.test.js`
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
|
||||
- [x] 7. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- The existing `MetricSubTeamView` and `MetricDeviceList` components are reused with minor prop adjustments — no new component creation needed for those
|
||||
- All backend endpoints are added to the existing `backend/routes/vclMultiVertical.js` file
|
||||
- All frontend changes are within `frontend/src/components/pages/CCPMetricsPage.js`
|
||||
- Property-based tests use `fast-check` (already in project dependencies)
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1", "1.2"] },
|
||||
{ "id": 1, "tasks": ["1.3", "1.4", "1.5", "1.6", "1.7", "3.1"] },
|
||||
{ "id": 2, "tasks": ["4.1"] },
|
||||
{ "id": 3, "tasks": ["4.2", "5.1"] },
|
||||
{ "id": 4, "tasks": ["5.2"] },
|
||||
{ "id": 5, "tasks": ["5.3", "6.1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "7a1ca671-3974-49b1-8e83-023077e758d5", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||
99
.kiro/specs/compliance-duplicate-chart-entries/bugfix.md
Normal file
99
.kiro/specs/compliance-duplicate-chart-entries/bugfix.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Bugfix Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Multiple compliance endpoints incorrectly key their queries by `compliance_uploads.id` (or by individual upload row) instead of by `compliance_uploads.report_date`. The compliance pipeline accepts one xlsx file per vertical (e.g., NTS_AEO, SDIT_CISO, TSI), so a single calendar date typically produces several `compliance_uploads` rows. Any query, aggregation, or "pick latest" logic that treats each upload as a distinct date — instead of grouping all uploads sharing a `report_date` — produces duplicated, fragmented, or silently dropped data.
|
||||
|
||||
The originally reported defect (GitLab issue #12, reported by nkapur) was the "Active Findings Over Time" chart on the Compliance page showing 3 entries for 5/11 after STEAM uploaded three vertical data sets that day. Investigation found that the same root cause — keying by `upload_id` instead of `report_date` — affects `GET /trends`, `GET /waterfall` (route handler `GET /top-recurring`), `GET /category-trend`, `GET /summary`, and the `compliance_snapshots` block in `persistUpload()`. This spec covers fixes for all five.
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Current Behavior (Defect)
|
||||
|
||||
#### /trends (originally reported)
|
||||
|
||||
1.1 WHEN multiple compliance uploads exist with the same `report_date` (due to per-vertical uploads) THEN the system returns one trend data point per upload row, producing duplicate x-axis entries on the chart
|
||||
|
||||
1.2 WHEN the chart renders multiple entries for the same date THEN the x-axis displays repeated date labels (e.g., three "05/11/25" entries) making the trend line misleading and unreadable
|
||||
|
||||
1.3 WHEN per-team counts are computed for duplicate-date uploads THEN the system counts items per individual `upload_id` rather than aggregating across all uploads sharing that date, resulting in fragmented per-team totals
|
||||
|
||||
#### /waterfall (route handler `GET /top-recurring`)
|
||||
|
||||
1.4 WHEN multiple compliance uploads exist with the same `report_date` THEN the underlying query `SELECT id, report_date, ... FROM compliance_uploads ORDER BY report_date ASC` returns one row per upload and `computeWaterfall()` emits one bar per row, producing multiple bars stacked under the same date label
|
||||
|
||||
1.5 WHEN `computeWaterfall()` carries `start` forward across multiple rows that share a `report_date` THEN each per-vertical row's `new_count`/`recurring_count`/`resolved_count` deltas are applied sequentially as if they were separate cycles, so the running `start` and `end` totals for that date are wrong (they reflect the last row's running balance rather than the date-level aggregate)
|
||||
|
||||
#### /category-trend
|
||||
|
||||
1.6 WHEN multiple compliance uploads exist with the same `report_date` THEN the query grouped by `cu.id, cu.report_date, category` returns one row per (upload, category) pair, producing duplicated stacked bars per date when the chart is keyed on `report_date`
|
||||
|
||||
1.7 WHEN per-category counts are surfaced for a date with multiple uploads THEN counts are reported per-vertical instead of aggregated across all verticals sharing that `report_date`, so no row in the response represents the full date-level category total
|
||||
|
||||
#### /summary
|
||||
|
||||
1.8 WHEN multiple uploads exist for the latest `report_date` THEN the query `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` (with fallback to `vertical = 'NTS_AEO'`) selects a single upload for that date and discards the `summary_json` of all other verticals, silently dropping their data
|
||||
|
||||
1.9 WHEN the summary returned by `/summary` is compared against `/trends`, `/waterfall`, or `/category-trend` for the same latest date THEN the figures do not reconcile, because `/summary` reflects one vertical's upload while the other endpoints aggregate (or duplicate) across all verticals
|
||||
|
||||
#### `compliance_snapshots` creation in `persistUpload()`
|
||||
|
||||
1.10 WHEN `persistUpload()` computes per-vertical compliance stats THEN the query filters only `WHERE team IS NOT NULL` and groups by `team`, with no filter or grouping on `vertical`, so item counts pulled from `compliance_items` are aggregated across every vertical present in the table
|
||||
|
||||
1.11 WHEN the resulting per-team totals are written into `compliance_snapshots` for a single vertical's upload THEN the `total_devices`, `compliant`, and `non_compliant` columns reflect cross-vertical totals rather than the snapshotted vertical, corrupting the monthly snapshot record
|
||||
|
||||
### Expected Behavior (Correct)
|
||||
|
||||
#### /trends (originally reported)
|
||||
|
||||
2.1 WHEN multiple compliance uploads exist with the same `report_date` THEN the system SHALL aggregate their counts (new_count, recurring_count, resolved_count, total_active) into a single trend data point per unique date
|
||||
|
||||
2.2 WHEN the chart renders trend data THEN each unique `report_date` SHALL appear exactly once on the x-axis regardless of how many upload records exist for that date
|
||||
|
||||
2.3 WHEN per-team counts are computed for a date with multiple uploads THEN the system SHALL aggregate team item counts across all uploads sharing that `report_date`, producing a single per-team total per date
|
||||
|
||||
#### /waterfall (route handler `GET /top-recurring`)
|
||||
|
||||
2.4 WHEN multiple compliance uploads exist with the same `report_date` THEN the system SHALL aggregate `new_count`, `recurring_count`, and `resolved_count` across all uploads sharing that `report_date` into a single per-date row before passing rows to `computeWaterfall()`
|
||||
|
||||
2.5 WHEN `computeWaterfall()` consumes the aggregated rows THEN it SHALL emit exactly one waterfall entry per unique `report_date` and the running `start`/`end` totals SHALL advance using each date's date-level aggregate deltas (not per-upload deltas)
|
||||
|
||||
#### /category-trend
|
||||
|
||||
2.6 WHEN multiple compliance uploads exist with the same `report_date` THEN the query SHALL group by `cu.report_date, category` (without `cu.id` in the GROUP BY) and `SUM`/`COUNT` items across all uploads sharing the date, producing one row per (date, category) pair
|
||||
|
||||
2.7 WHEN per-category counts are returned for a date with multiple uploads THEN the `count` field SHALL be the sum of items in that category across every upload for that `report_date`
|
||||
|
||||
#### /summary
|
||||
|
||||
2.8 WHEN multiple uploads exist for the latest `report_date` THEN the system SHALL either (a) merge the `summary_json` of all uploads sharing that date into a single combined summary response, or (b) return a documented, well-defined selection (e.g., a named "primary" vertical) along with metadata indicating which uploads were considered, rather than silently picking one by `ORDER BY id DESC LIMIT 1`
|
||||
|
||||
2.9 WHEN the response is constructed for a date with multiple uploads THEN the `upload` field SHALL identify the set of uploads that contributed to the response (or, if a single representative is returned, the response SHALL include a flag/field indicating other uploads exist for the same date that were not merged)
|
||||
|
||||
#### `compliance_snapshots` creation in `persistUpload()`
|
||||
|
||||
2.10 WHEN `persistUpload()` computes per-vertical compliance stats THEN the query SHALL filter `compliance_items` by the `vertical` of the upload being persisted (in addition to `team IS NOT NULL`) and group by `vertical, team`, so each snapshot row reflects only the items belonging to that vertical
|
||||
|
||||
2.11 WHEN snapshots are written into `compliance_snapshots` THEN the `total_devices`, `compliant`, and `non_compliant` values SHALL match the items belonging to the snapshotted vertical only and SHALL NOT be inflated by items from other verticals
|
||||
|
||||
### Unchanged Behavior (Regression Prevention)
|
||||
|
||||
3.1 WHEN only one compliance upload exists per `report_date` (single-file upload workflow) THEN the system SHALL CONTINUE TO return that date's counts unchanged as a single trend data point
|
||||
|
||||
3.2 WHEN the chart displays trend data THEN the system SHALL CONTINUE TO show all existing data fields (new_count, recurring_count, resolved_count, total_active, per-team breakdowns) with correct values
|
||||
|
||||
3.3 WHEN no compliance uploads exist THEN the system SHALL CONTINUE TO return an empty trends array and the chart SHALL CONTINUE TO display the "no data" state
|
||||
|
||||
3.4 WHEN only one compliance upload exists per `report_date` THEN `GET /waterfall` SHALL CONTINUE TO emit one entry per date with the same `start`, `new_count`, `recurring_count`, `resolved_count`, and `end` fields and the same running-total semantics as before
|
||||
|
||||
3.5 WHEN only one compliance upload exists per `report_date` THEN `GET /category-trend` SHALL CONTINUE TO return one row per (date, category) pair with the same `report_date`, `category`, and `count` field shape as before
|
||||
|
||||
3.6 WHEN only one compliance upload exists for the latest `report_date` THEN `GET /summary` SHALL CONTINUE TO return the same `entries`, `overall_scores`, and `upload` shape as before, including the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting which upload's summary to surface
|
||||
|
||||
3.7 WHEN `/summary` is called with a `team` query parameter THEN the system SHALL CONTINUE TO filter `entries` by the requested team and SHALL CONTINUE TO reject teams not in `ALLOWED_TEAMS` with HTTP 400
|
||||
|
||||
3.8 WHEN `persistUpload()` writes a snapshot for a vertical that is the only vertical present in `compliance_items` for that month THEN the snapshot row's `total_devices`, `compliant`, `non_compliant`, and `compliance_pct` SHALL CONTINUE TO be identical to the pre-fix values (no behavioural change in the single-vertical case)
|
||||
|
||||
3.9 WHEN `persistUpload()` encounters an error during snapshot creation THEN the system SHALL CONTINUE TO log the error and complete the upload commit successfully (snapshot creation remains non-critical)
|
||||
|
||||
3.10 WHEN any of these endpoints are queried with no matching data (no uploads, no items for a vertical, no items in a category) THEN the system SHALL CONTINUE TO return the existing empty-state response shapes
|
||||
395
.kiro/specs/compliance-duplicate-chart-entries/design.md
Normal file
395
.kiro/specs/compliance-duplicate-chart-entries/design.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# Compliance Duplicate Chart Entries Bugfix Design
|
||||
|
||||
## Overview
|
||||
|
||||
Five compliance endpoints (`GET /trends`, `GET /top-recurring`, `GET /category-trend`, `GET /summary`) and the `compliance_snapshots` block inside `persistUpload()` all share the same root cause: they key by `compliance_uploads.id` (one row per uploaded xlsx) instead of by `compliance_uploads.report_date` (the calendar date the report covers). Because the compliance pipeline accepts one xlsx per vertical (NTS_AEO, SDIT_CISO, TSI), a single `report_date` typically maps to several `compliance_uploads` rows, and any query that does not aggregate over `report_date` produces duplicated, fragmented, or silently dropped data.
|
||||
|
||||
The fix is uniform across endpoints: rewrite the SQL so the result set has exactly one row per unique `report_date`, using `GROUP BY report_date` with `SUM` aggregations for count-style endpoints and `DISTINCT ON (report_date)` for the latest-snapshot endpoint. The `persistUpload()` snapshot block is fixed by adding a `vertical` filter so per-vertical snapshots are no longer cross-contaminated by other verticals' items.
|
||||
|
||||
The implementation is intentionally minimal: each fix changes a single SQL statement (and, in one case, a small JavaScript loop). No frontend changes are required — the chart components already key on `report_date` and will render correctly once the API returns one row per date.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Bug_Condition (C)**: The condition that triggers the bug — two or more rows in `compliance_uploads` share the same `report_date` (i.e., a multi-vertical upload day).
|
||||
- **Property (P)**: The desired behavior when C holds — each affected endpoint returns exactly one entry per unique `report_date`, and the values aggregated across uploads for that date reconcile with the underlying `compliance_items` totals.
|
||||
- **Preservation**: Behavior on dates with a single upload row, on the empty-data response shape, and on unrelated query parameters (e.g., `team` filter on `/summary`) — all must be byte-for-byte unchanged.
|
||||
- **report_date**: `TEXT` column on `compliance_uploads` storing the reporting period the xlsx covers (e.g., `2025-05-11`). One date can have multiple upload rows when multiple verticals are uploaded for that date.
|
||||
- **vertical**: `TEXT` column on `compliance_uploads` and `compliance_items` identifying which xlsx (NTS_AEO, SDIT_CISO, TSI) an upload or item belongs to. `NULL` indicates a legacy AEO-only upload.
|
||||
- **persistUpload()**: Function in `backend/routes/compliance.js` (lines 81–192) that writes a parsed upload to the DB inside a transaction and then writes per-vertical snapshots into `compliance_snapshots`.
|
||||
- **computeWaterfall(uploads)**: Pure helper in `backend/routes/compliance.js` (lines 235–243) that takes an ordered list of upload rows and emits one waterfall entry per row, carrying the running `start` forward.
|
||||
|
||||
## Bug Details
|
||||
|
||||
### Bug Condition
|
||||
|
||||
The bug manifests when two or more `compliance_uploads` rows share the same `report_date`. This happens whenever the operator uploads more than one vertical xlsx for the same reporting cycle (the documented multi-vertical workflow). The five affected code paths each produce one row per upload instead of aggregating to one row per `report_date`.
|
||||
|
||||
**Formal Specification:**
|
||||
```
|
||||
FUNCTION isBugCondition(uploads)
|
||||
INPUT: uploads — list of compliance_uploads rows
|
||||
OUTPUT: boolean
|
||||
|
||||
// The bug condition is triggered for any report_date that has more than one upload row
|
||||
GROUP uploads BY report_date INTO groups
|
||||
RETURN EXISTS group IN groups WHERE COUNT(group) > 1
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
For a single endpoint response to be considered buggy, the API output must additionally fail one of the following invariants (the per-endpoint manifestation of the same root cause):
|
||||
|
||||
```
|
||||
FUNCTION isBuggyResponse(endpoint, response)
|
||||
CASE endpoint OF
|
||||
'/trends': RETURN COUNT(response.trends) != COUNT(DISTINCT report_date IN compliance_uploads)
|
||||
'/top-recurring': RETURN COUNT(response.waterfall) != COUNT(DISTINCT report_date IN compliance_uploads)
|
||||
'/category-trend': RETURN EXISTS (date, category) WITH COUNT(*) > 1 IN response.categoryTrend
|
||||
'/summary': RETURN response.upload represents only one of N>1 uploads sharing the latest report_date
|
||||
AND no flag indicates other uploads exist for that date
|
||||
'persistUpload': RETURN snapshots.total_devices > items_belonging_to_this_vertical_only
|
||||
END CASE
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
The originally reported case (GitLab issue #12, 2025-05-11) and the four sibling manifestations:
|
||||
|
||||
- **`/trends`** — STEAM uploads three xlsx files for `2025-05-11` (one per vertical). The chart shows three "05/11/25" entries on the x-axis instead of one. Expected: a single 05/11/25 point whose `new_count`/`recurring_count`/`resolved_count`/`total_active` are the sums of the three uploads' counts.
|
||||
|
||||
- **`/top-recurring`** — Same three uploads. `computeWaterfall()` receives three rows for `2025-05-11` and emits three bars stacked on the same date. Worse, because `start` carries forward across rows, the second and third bars' `start` reflects the first/second row's `end`, so the three bars in aggregate misrepresent the date-level deltas. Expected: one bar for `2025-05-11` whose `new_count`/`recurring_count`/`resolved_count` are summed across the three uploads, and whose `start` carries from the previous date's `end`.
|
||||
|
||||
- **`/category-trend`** — Same three uploads, each with category-tagged items. The query groups by `(cu.id, cu.report_date, category)` and returns up to `3 × |categories|` rows for `2025-05-11`. The frontend stacks these as duplicated category bars per date. Expected: one row per `(2025-05-11, category)` pair with `count` summed across the three uploads.
|
||||
|
||||
- **`/summary`** — On `2025-05-11`, three uploads exist. The query `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` (with fallback to `vertical = 'NTS_AEO'`) silently picks one and the other two verticals' `summary_json` is dropped. Expected: either the response merges all three uploads' `entries` and `overall_scores`, or the response includes a `multi_vertical_uploads` array identifying the other uploads that exist for the same `report_date` so the caller knows the response is partial.
|
||||
|
||||
- **Edge case — `persistUpload()` snapshot** — When SDIT_CISO is being persisted on `2025-05-11`, the snapshot query reads `compliance_items WHERE team IS NOT NULL` with no `vertical` filter, so the resulting per-team `total_devices`/`compliant`/`non_compliant` counts include items that belong to NTS_AEO and TSI as well. Expected: the snapshot query filters by the upload's `vertical` and groups by `(vertical, team)`.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Preservation Requirements
|
||||
|
||||
**Unchanged Behaviors:**
|
||||
- Single-upload-per-date dates (legacy AEO-only workflow): every endpoint returns the same numbers, in the same shape, in the same order as before the fix.
|
||||
- Empty-data responses: `/trends` returns `{ trends: [] }`, `/top-recurring` returns `{ waterfall: [] }`, `/category-trend` returns `{ categoryTrend: [] }`, `/summary` returns `{ entries: [], overall_scores: {}, upload: null }`.
|
||||
- `/summary` `team` query parameter: still filters `entries` server-side, still rejects non-`ALLOWED_TEAMS` values with HTTP 400.
|
||||
- `/summary` `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting which upload's `summary_json` to surface (only the additional metadata about sibling uploads is new).
|
||||
- `persistUpload()` error handling: snapshot creation remains wrapped in a `try/catch` that logs but does not fail the upload commit.
|
||||
- `compliance_snapshots` rows for months with only a single vertical present in `compliance_items`: identical values to the pre-fix output.
|
||||
- Frontend chart components: no changes required. They already key on `report_date` and consume the existing response shapes.
|
||||
|
||||
**Scope:**
|
||||
All endpoint inputs that do not involve `report_date` collisions (single-upload dates, empty datasets, error paths, query-parameter filtering) must be byte-for-byte identical to the pre-fix output. The fix only changes what happens when two or more `compliance_uploads` rows share a `report_date`.
|
||||
|
||||
## Hypothesized Root Cause
|
||||
|
||||
All five sites have the same shape of bug — keying by `id` instead of `report_date` — but with slightly different mechanics. Listing them explicitly so the test plan can confirm or refute each one:
|
||||
|
||||
1. **`/trends` — per-row mapping over uploads.** The handler runs `SELECT id, report_date, ... FROM compliance_uploads ORDER BY report_date ASC` and `.map()`s each row into a trend entry. Per-team counts are pre-aggregated by `upload_id` and looked up by `u.id`, so duplicate-date rows produce duplicate-date trend entries with split per-team counts.
|
||||
|
||||
2. **`/top-recurring` — `computeWaterfall()` receives per-row data.** The query is identical to `/trends`'s upload query and `computeWaterfall()` carries a stateful `start` forward across rows. Three rows for the same date become three bars whose `start`/`end` running totals are wrong relative to the date-level aggregate.
|
||||
|
||||
3. **`/category-trend` — `GROUP BY cu.id, cu.report_date, category`.** Including `cu.id` in the `GROUP BY` defeats date-level aggregation; one upload row's items get their own (date, category) group instead of summing into the date-level group.
|
||||
|
||||
4. **`/summary` — `ORDER BY id DESC LIMIT 1`.** The query selects a single representative upload for the latest date and discards every other upload sharing that date. This is a "select latest by row id" pattern that does not consider `report_date` ties.
|
||||
|
||||
5. **`persistUpload()` snapshot block — missing `vertical` filter.** The snapshot query reads `compliance_items WHERE team IS NOT NULL GROUP BY team` with no `vertical` predicate. The query was correct when there was one vertical (AEO-only legacy) and silently broke when the multi-vertical migration added a `vertical` column without updating this query.
|
||||
|
||||
The common structural cause is that the multi-vertical migration (`add_vcl_multi_vertical.js`) added a `vertical` column to `compliance_uploads` and `compliance_items` but did not audit existing read queries for the new "many uploads share a `report_date`" reality.
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
Property 1: Bug Condition — `/trends` returns one entry per unique report_date
|
||||
|
||||
_For any_ set of `compliance_uploads` rows where two or more rows share a `report_date`, the response from `GET /trends` SHALL contain exactly one entry per unique `report_date`, with `new_count`, `recurring_count`, `resolved_count`, and `total_active` equal to the SUM of those columns over all uploads sharing that date, and per-team counts equal to the sum of `compliance_items` rows for that team across all those uploads.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3**
|
||||
|
||||
Property 2: Bug Condition — `/top-recurring` waterfall has one bar per unique report_date with correct running totals
|
||||
|
||||
_For any_ set of `compliance_uploads` rows where two or more rows share a `report_date`, the response from `GET /top-recurring` SHALL contain exactly one waterfall entry per unique `report_date`, the entry's `new_count`/`recurring_count`/`resolved_count` SHALL equal the sum of those columns over all uploads sharing that date, and the running invariant `entry[i].end == entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` SHALL hold with `entry[i].start == entry[i-1].end` for adjacent entries (and `entry[0].start == 0`).
|
||||
|
||||
**Validates: Requirements 2.4, 2.5**
|
||||
|
||||
Property 3: Bug Condition — `/category-trend` returns one row per (date, category)
|
||||
|
||||
_For any_ set of `compliance_uploads` and `compliance_items` rows, the response from `GET /category-trend` SHALL contain exactly one entry per unique `(report_date, category)` pair, and each entry's `count` SHALL equal the total number of `compliance_items` for that category across every upload sharing that `report_date`.
|
||||
|
||||
**Validates: Requirements 2.6, 2.7**
|
||||
|
||||
Property 4: Bug Condition — `/summary` does not silently drop sibling uploads
|
||||
|
||||
_For any_ set of `compliance_uploads` rows where two or more rows share the latest `report_date`, the response from `GET /summary` SHALL either (a) include a merged view of all sibling uploads' `entries` and `overall_scores`, or (b) include a non-empty `multi_vertical_uploads` field listing the IDs and verticals of the other uploads for that date that were not used to populate the response. The response SHALL NOT silently drop sibling uploads.
|
||||
|
||||
**Validates: Requirements 2.8, 2.9**
|
||||
|
||||
Property 5: Bug Condition — `persistUpload()` snapshot reflects only the snapshotted vertical
|
||||
|
||||
_For any_ `persistUpload()` invocation with a non-NULL `vertical`, the rows written into `compliance_snapshots` for the current month SHALL have `total_devices`, `compliant`, and `non_compliant` values equal to the counts derived from `compliance_items` filtered to the snapshotted vertical only. No item from another vertical SHALL contribute to those counts.
|
||||
|
||||
**Validates: Requirements 2.10, 2.11**
|
||||
|
||||
Property 6: Preservation — Per-endpoint cross-date sums equal source-data totals
|
||||
|
||||
_For any_ set of uploads, summing `new_count` (and likewise `recurring_count`, `resolved_count`) across every entry in `GET /trends` SHALL equal the corresponding `SUM(new_count)` over `compliance_uploads`. Similarly, summing `count` across every entry in `GET /category-trend` SHALL equal `COUNT(*)` of `compliance_items` joined to `compliance_uploads`. This holds whether or not any date has duplicate uploads.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2**
|
||||
|
||||
Property 7: Preservation — Single-upload-per-date dates are unchanged
|
||||
|
||||
_For any_ set of `compliance_uploads` where every `report_date` has exactly one row, the responses from `/trends`, `/top-recurring`, `/category-trend`, and `/summary` (and the `compliance_snapshots` rows written by `persistUpload()`) SHALL be identical to the pre-fix output for the same input. The fix SHALL NOT change behavior on the single-upload-per-date case.
|
||||
|
||||
**Validates: Requirements 3.1, 3.4, 3.5, 3.6, 3.8**
|
||||
|
||||
Property 8: Preservation — Empty-data and error-path responses are unchanged
|
||||
|
||||
_For any_ empty dataset (no uploads, no matching items, no items in a category), each affected endpoint SHALL return the same empty-state response shape as before the fix. `/summary` with a non-`ALLOWED_TEAMS` `team` parameter SHALL still respond `400`. `persistUpload()` snapshot errors SHALL still be caught and logged without failing the upload commit.
|
||||
|
||||
**Validates: Requirements 3.3, 3.7, 3.9, 3.10**
|
||||
|
||||
## Fix Implementation
|
||||
|
||||
### Changes Required
|
||||
|
||||
All changes are in `backend/routes/compliance.js`. No schema migration, no new column, no frontend change.
|
||||
|
||||
#### Fix 1: `GET /trends` — aggregate uploads and team counts by `report_date`
|
||||
|
||||
**Function**: `router.get('/trends', ...)` (around line 768)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Replace the `compliance_uploads` query so it groups by `report_date` and sums the count columns:
|
||||
```sql
|
||||
SELECT report_date,
|
||||
SUM(COALESCE(new_count, 0))::int AS new_count,
|
||||
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
||||
SUM(COALESCE(resolved_count, 0))::int AS resolved_count,
|
||||
SUM(COALESCE(new_count, 0) + COALESCE(recurring_count, 0))::int AS total_active
|
||||
FROM compliance_uploads
|
||||
WHERE report_date IS NOT NULL
|
||||
GROUP BY report_date
|
||||
ORDER BY report_date ASC
|
||||
```
|
||||
2. Replace the per-team `compliance_items` query so it joins to `compliance_uploads` and groups by `(report_date, team)` instead of `(upload_id, team)`:
|
||||
```sql
|
||||
SELECT cu.report_date, ci.team, COUNT(ci.id)::int AS count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.team IS NOT NULL AND cu.report_date IS NOT NULL
|
||||
GROUP BY cu.report_date, ci.team
|
||||
```
|
||||
3. Change the `teamMap` keyed lookup from `teamMap[u.id]` to `teamMap[u.report_date]` and rebuild `trends` from the per-date upload rows.
|
||||
|
||||
#### Fix 2: `GET /top-recurring` — aggregate uploads by `report_date` before passing to `computeWaterfall()`
|
||||
|
||||
**Function**: `router.get('/top-recurring', ...)` (around line 818)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Replace the query with the same `GROUP BY report_date` pattern used in `/trends` (without `id`, since `computeWaterfall()` only needs `report_date`, `new_count`, `recurring_count`, `resolved_count`):
|
||||
```sql
|
||||
SELECT report_date,
|
||||
SUM(COALESCE(new_count, 0))::int AS new_count,
|
||||
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
||||
SUM(COALESCE(resolved_count, 0))::int AS resolved_count
|
||||
FROM compliance_uploads
|
||||
WHERE report_date IS NOT NULL
|
||||
GROUP BY report_date
|
||||
ORDER BY report_date ASC
|
||||
```
|
||||
2. `computeWaterfall()` itself does not change — it already advances `start` correctly when fed one row per date. The fix is purely in the SQL.
|
||||
|
||||
#### Fix 3: `GET /category-trend` — drop `cu.id` from `GROUP BY`
|
||||
|
||||
**Function**: `router.get('/category-trend', ...)` (around line 838)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Remove `cu.id` from the `GROUP BY` clause so the grouping is by `(report_date, category)` only:
|
||||
```sql
|
||||
SELECT cu.report_date,
|
||||
COALESCE(ci.category, 'Unknown') AS category,
|
||||
COUNT(ci.id)::int AS count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||
WHERE cu.report_date IS NOT NULL
|
||||
GROUP BY cu.report_date, COALESCE(ci.category, 'Unknown')
|
||||
ORDER BY cu.report_date ASC, category ASC
|
||||
```
|
||||
2. The response shape (`{ categoryTrend: Array<{ report_date, category, count }> }`) does not change. Only the row count for multi-vertical dates changes (collapsing duplicates into sums).
|
||||
|
||||
#### Fix 4: `GET /summary` — disclose sibling uploads for the latest date
|
||||
|
||||
**Function**: `router.get('/summary', ...)` (around line 495)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Keep the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for choosing the primary upload's `summary_json` (this preserves the legacy single-upload behavior).
|
||||
2. After resolving `latestUpload`, run a second query to find sibling uploads sharing the same `report_date`:
|
||||
```sql
|
||||
SELECT id, vertical, uploaded_at
|
||||
FROM compliance_uploads
|
||||
WHERE report_date = $1 AND id != $2
|
||||
ORDER BY id ASC
|
||||
```
|
||||
3. Add `multi_vertical_uploads` to the response when sibling uploads exist:
|
||||
```javascript
|
||||
res.json({
|
||||
entries,
|
||||
overall_scores: summary.overall_scores || {},
|
||||
upload: { id, report_date, uploaded_at },
|
||||
multi_vertical_uploads: siblings.map(s => ({ id: s.id, vertical: s.vertical, uploaded_at: s.uploaded_at })),
|
||||
});
|
||||
```
|
||||
4. When no sibling uploads exist (single-upload-per-date case), `multi_vertical_uploads` is `[]` (or omitted — see open question in test plan).
|
||||
|
||||
This is the conservative option (b) from requirement 2.8 — return a documented selection plus metadata about siblings — rather than option (a) full server-side merge. Option (b) is chosen because (i) the `summary_json` schema is per-vertical and merging would require reconciliation logic that doesn't currently exist, and (ii) the existing fallback selection (NTS_AEO) is the established representative for the legacy AEO chart on the Compliance page.
|
||||
|
||||
#### Fix 5: `persistUpload()` snapshot block — filter and group by `vertical`
|
||||
|
||||
**Function**: `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||
|
||||
**Specific Changes**:
|
||||
1. Determine the upload's `vertical` (read it from the upload row immediately after the `RETURNING id` insert, or accept it as a parameter to `persistUpload()`).
|
||||
2. Replace the `verticalStats` query with one that filters by the upload's `vertical` and groups by `(vertical, team)`:
|
||||
```sql
|
||||
SELECT vertical, team,
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END)::int AS compliant,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1
|
||||
GROUP BY vertical, team
|
||||
```
|
||||
(`IS NOT DISTINCT FROM` handles the legacy `vertical IS NULL` case correctly, so AEO-only uploads keep their previous semantics.)
|
||||
3. The `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` already keys snapshots by `vertical`, so no change is required there. However, the `vertical` value passed in must come from the query result, not from `team AS vertical` (which conflates the team and vertical concepts).
|
||||
4. If the per-snapshot-row "vertical" identity needs to remain `team` for back-compat reasons, leave the `INSERT` mapping unchanged but ensure the underlying counts are filtered to the upload's actual `vertical`. Confirm via inspection of `compliance_snapshots` consumers (`/vcl/stats`) before finalising.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Validation Approach
|
||||
|
||||
The bug condition is straightforward to construct: insert two `compliance_uploads` rows with the same `report_date` and matching `compliance_items`, then call each affected endpoint. The two-phase approach is to first run the tests against the unfixed code to confirm the duplication/silent-drop counterexamples, then run the same tests against the fixed code and add property-based tests that explore the input space more broadly.
|
||||
|
||||
### Exploratory Bug Condition Checking
|
||||
|
||||
**Goal**: Surface counterexamples that demonstrate each of the five manifestations BEFORE implementing the fix. Confirm or refute the root cause analysis for each endpoint independently — they share a structural cause but the SQL details differ.
|
||||
|
||||
**Test Plan**: Seed a clean test database with a fixture representing the original GitLab #12 scenario (three uploads for `2025-05-11`, one each for NTS_AEO, SDIT_CISO, TSI, with realistic `compliance_items`). Call each affected endpoint and assert the buggy invariants. Run on UNFIXED code first.
|
||||
|
||||
**Test Cases**:
|
||||
|
||||
1. **`/trends` Duplicate Date Test** — Insert three uploads for `2025-05-11` (verticals NTS_AEO, SDIT_CISO, TSI), each with distinct `new_count`/`recurring_count`/`resolved_count` and matching `compliance_items` per team. Call `GET /trends`. Assert `response.trends.filter(t => t.report_date === '2025-05-11').length === 1`. (will fail on unfixed code — returns 3)
|
||||
|
||||
2. **`/top-recurring` Duplicate Bar Test** — Same fixture. Call `GET /top-recurring`. Assert `response.waterfall.filter(w => w.date === '2025-05-11').length === 1` AND assert the running invariant `waterfall[i].end === waterfall[i].start + waterfall[i].new_count + waterfall[i].recurring_count - waterfall[i].resolved_count` holds for every `i`. (will fail on unfixed code — returns 3 bars and the running totals reflect mid-row state, not date-level aggregate)
|
||||
|
||||
3. **`/category-trend` Duplicate (date, category) Test** — Same fixture, plus items tagged with two categories (e.g., "Patching" and "Configuration"). Call `GET /category-trend`. Assert that for each `(report_date, category)` pair, `response.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Patching').length === 1`. (will fail on unfixed code — returns 3 rows per category)
|
||||
|
||||
4. **`/summary` Sibling Disclosure Test** — Same fixture (three uploads for `2025-05-11`, latest date). Call `GET /summary`. Assert either (a) the response merges `entries` from all three uploads, or (b) `response.multi_vertical_uploads.length === 2`. (will fail on unfixed code — silently picks one upload, the other two are dropped without any indication)
|
||||
|
||||
5. **`persistUpload()` Cross-Vertical Contamination Test** — Pre-populate `compliance_items` with rows from multiple verticals (e.g., NTS_AEO has 100 active items, SDIT_CISO has 50 active items). Call `persistUpload()` with a fresh SDIT_CISO upload. Read back the `compliance_snapshots` row for the current month and SDIT_CISO. Assert `total_devices` reflects only SDIT_CISO items, not the combined 150. (will fail on unfixed code — total includes both verticals)
|
||||
|
||||
6. **Edge Case — Single-Upload-Per-Date Regression Test** — Insert a fixture with a single upload per date for three dates. Call all four read endpoints and capture responses. Apply the fix, re-run, and assert response equality (byte-for-byte). (should pass on unfixed code; will pass on fixed code; protects the preservation property)
|
||||
|
||||
**Expected Counterexamples**:
|
||||
- `/trends` returns N trend entries for a date with N uploads (N > 1). Cause: per-row `.map()` over uploads instead of date-level aggregation.
|
||||
- `/top-recurring` returns N waterfall bars for a date with N uploads. Cause: same per-row pattern, plus `computeWaterfall()` carries `start` forward across the duplicate-date rows.
|
||||
- `/category-trend` returns N × |categories| rows for a date with N uploads. Cause: `cu.id` is in the `GROUP BY` clause.
|
||||
- `/summary` returns one upload's `summary_json` and silently drops siblings. Cause: `ORDER BY id DESC LIMIT 1` with no `report_date`-tie handling.
|
||||
- `persistUpload()` writes inflated `total_devices`. Cause: missing `WHERE vertical = $1` and `GROUP BY vertical, team` in the snapshot query.
|
||||
|
||||
### Fix Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition holds (any `report_date` shared by two or more uploads), each fixed endpoint produces the expected aggregated/disclosed result.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL (uploads, items) WHERE EXISTS report_date d WITH COUNT(uploads WHERE report_date = d) > 1 DO
|
||||
trends_response := GET_trends_fixed(uploads, items)
|
||||
waterfall_response := GET_top_recurring_fixed(uploads, items)
|
||||
cattrend_response := GET_category_trend_fixed(uploads, items)
|
||||
summary_response := GET_summary_fixed(uploads, items)
|
||||
snapshot_rows := persistUpload_fixed(new_upload_for_some_vertical, items)
|
||||
|
||||
ASSERT one_entry_per_date(trends_response.trends)
|
||||
ASSERT one_entry_per_date(waterfall_response.waterfall) AND running_invariant_holds(waterfall_response.waterfall)
|
||||
ASSERT one_entry_per_date_category_pair(cattrend_response.categoryTrend)
|
||||
ASSERT siblings_disclosed(summary_response, uploads)
|
||||
ASSERT snapshots_filtered_to_vertical(snapshot_rows, new_upload.vertical, items)
|
||||
END FOR
|
||||
```
|
||||
|
||||
### Preservation Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition does NOT hold (every `report_date` has exactly one upload row), the fixed endpoints produce results identical to the original endpoints.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL (uploads, items) WHERE FORALL report_date d, COUNT(uploads WHERE report_date = d) <= 1 DO
|
||||
ASSERT GET_trends_original(uploads, items) = GET_trends_fixed(uploads, items)
|
||||
ASSERT GET_top_recurring_original(uploads, items) = GET_top_recurring_fixed(uploads, items)
|
||||
ASSERT GET_category_trend_original(uploads, items) = GET_category_trend_fixed(uploads, items)
|
||||
ASSERT GET_summary_original(uploads, items) = GET_summary_fixed(uploads, items)
|
||||
ASSERT persistUpload_original(upload, items).snapshots = persistUpload_fixed(upload, items).snapshots
|
||||
END FOR
|
||||
```
|
||||
|
||||
**Testing Approach**: Property-based testing is the right fit for preservation checking here:
|
||||
- The single-upload-per-date input space is large (any number of dates, any combination of counts, any team distribution, any category mix, any vertical), and exhaustive enumeration is impractical.
|
||||
- The preservation property is a strict equality, which is well-suited to PBT shrinking (any counterexample is a small fixture demonstrating a behavior change).
|
||||
- The legacy AEO-only data shape (`vertical IS NULL`) must be exercised, which falls naturally out of generators that include null verticals.
|
||||
|
||||
**Test Plan**: Capture responses from the unfixed code on single-upload-per-date fixtures (snapshot tests). After applying the fix, re-run the same fixtures and assert equality. Then run a property-based generator that produces random single-upload-per-date scenarios and asserts the same equality.
|
||||
|
||||
**Test Cases**:
|
||||
1. **Snapshot Equality — Empty State** — Empty `compliance_uploads`. All four endpoints return their documented empty-state shapes. Snapshot-test before and after the fix.
|
||||
2. **Snapshot Equality — Single AEO-Only Upload** — One upload with `vertical IS NULL`, classic legacy fixture. Capture pre-fix responses, apply fix, assert equality.
|
||||
3. **Snapshot Equality — Multiple Single-Upload Dates** — Five dates, one upload each, varied `vertical` values. Capture pre-fix responses, apply fix, assert equality.
|
||||
4. **`/summary` Team Filter Preservation** — Latest upload exists, `?team=STEAM` parameter is supplied. Assert `entries` is filtered to `team === 'STEAM'` rows. Assert non-`ALLOWED_TEAMS` value (e.g., `?team=OTHER`) returns HTTP 400.
|
||||
5. **`persistUpload()` Snapshot Equality — Single-Vertical Month** — Pre-populate `compliance_items` with rows from a single vertical only. Run `persistUpload()` for that vertical. Assert the resulting `compliance_snapshots` rows are identical pre-fix and post-fix.
|
||||
6. **Error Path Preservation** — Force a snapshot query failure (e.g., transient DB error). Assert `persistUpload()` still commits the upload and the error is logged but not surfaced to the caller.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `/trends` aggregation: two uploads sharing a `report_date`, one upload alone for an earlier date. Assert response has 2 entries and `new_count` for the shared date equals the sum of the two uploads.
|
||||
- `/top-recurring` aggregation and running totals: same fixture as above. Assert 2 waterfall entries and the running `start`/`end` invariant.
|
||||
- `/category-trend` aggregation: two uploads sharing a `report_date`, items tagged with two categories. Assert one row per `(date, category)` pair with summed counts.
|
||||
- `/summary` sibling disclosure: three uploads sharing the latest date. Assert response shape matches the chosen disclosure approach (option (b)).
|
||||
- `/summary` team filter: same upload, with and without `?team=STEAM`.
|
||||
- `persistUpload()` per-vertical snapshot: items in two verticals, run upload for one, assert snapshots for that vertical do not include the other vertical's items.
|
||||
- `persistUpload()` legacy AEO-only path (`vertical IS NULL`): unchanged behavior.
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
- **`/trends` aggregation property** — Generate a random list of `(report_date, new_count, recurring_count, resolved_count)` upload tuples (with possible date collisions). Generate matching per-team item counts. Assert the response has exactly one entry per unique `report_date` AND for each entry, `new_count` equals the SUM of input `new_count`s for that date (likewise the other count fields and per-team counts).
|
||||
- **`/top-recurring` running invariant property** — Same generator. Assert the response has one bar per unique `report_date` AND for every adjacent pair of entries, `entry[i].start === entry[i-1].end`, AND `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count`.
|
||||
- **`/category-trend` total-conservation property** — Generate a random set of `compliance_items` and uploads. Assert `SUM(response.categoryTrend.map(c => c.count)) === total number of compliance_items joined to non-null-report_date uploads`. This holds whether or not any date has multiple uploads.
|
||||
- **`/summary` sibling-disclosure property** — Generate a random set of uploads with possible duplicate `report_dates`. Pick the latest date. Assert that if any sibling upload exists for that date, the response contains a non-empty `multi_vertical_uploads` array referencing every sibling upload's id.
|
||||
- **`persistUpload()` vertical-isolation property** — Generate two non-empty disjoint sets of `compliance_items`, one per vertical. Insert both. Run `persistUpload()` for vertical A. Assert the resulting `compliance_snapshots` rows for vertical A reflect only set-A items (count of distinct hostnames matches).
|
||||
- **Cross-endpoint preservation property** — Generate any fixture where every `report_date` has exactly one upload row. Assert all five fixed endpoints produce byte-for-byte identical results to the original endpoints.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Full upload-to-chart flow: upload three xlsx files (one per vertical) with the same `report_date` via `POST /preview` + `POST /commit`, then call `/trends`, `/top-recurring`, `/category-trend`, `/summary` and verify all four return the expected aggregated/disclosed results.
|
||||
- Compliance Charts panel render: load `ComplianceChartsPanel.js` with a multi-vertical-day fixture and assert (via DOM snapshot) the x-axis shows each date exactly once on `Active Findings Over Time` and `Change per Report Cycle`.
|
||||
- Snapshot consumer regression: after running `persistUpload()` with the fix, call `/vcl/stats` (which reads `compliance_snapshots`) and verify per-vertical `compliance_pct` is unchanged from the pre-fix value when only one vertical's items are present, and is corrected when multiple verticals are present.
|
||||
|
||||
### Test Fixtures Required
|
||||
|
||||
The following fixtures are needed and can be reused across all five endpoints' tests:
|
||||
|
||||
1. **`fixture_empty`** — No `compliance_uploads`, no `compliance_items`. Used by the empty-state preservation tests.
|
||||
|
||||
2. **`fixture_single_upload_aeo_legacy`** — One `compliance_uploads` row with `vertical IS NULL`, `report_date = '2025-04-01'`, with ~20 `compliance_items` distributed across the four teams. Used by the legacy-path preservation tests.
|
||||
|
||||
3. **`fixture_single_upload_per_date`** — Five `compliance_uploads` rows, each with a distinct `report_date` (`2025-04-01` through `2025-05-01`), each with a distinct `vertical` value among `{NTS_AEO, SDIT_CISO, TSI, NULL, NTS_AEO}`. Used by the broader preservation tests and by `/category-trend` total-conservation.
|
||||
|
||||
4. **`fixture_multi_vertical_single_date`** — Three `compliance_uploads` rows all with `report_date = '2025-05-11'`, verticals NTS_AEO/SDIT_CISO/TSI, each with distinct `new_count`/`recurring_count`/`resolved_count` and 5–10 `compliance_items` per upload spanning multiple teams and categories. This is the canonical bug-condition fixture and reproduces the original GitLab #12 scenario.
|
||||
|
||||
5. **`fixture_mixed_history`** — Combination of `fixture_single_upload_per_date` and `fixture_multi_vertical_single_date` — multiple dates, some with single uploads, some with two or three. Used by the property-based tests as a realistic state-of-the-world fixture.
|
||||
|
||||
6. **`fixture_cross_vertical_items`** — Two non-empty disjoint sets of `compliance_items`, one tagged `vertical = 'NTS_AEO'` and one tagged `vertical = 'SDIT_CISO'`, sharing some hostnames between verticals to ensure the count-distinct logic is exercised. Used by the `persistUpload()` vertical-isolation tests.
|
||||
|
||||
7. **`fixture_pbt_generators`** — fast-check (or equivalent) arbitraries:
|
||||
- `arbReportDate`: ISO date string in a bounded range (e.g., last 90 days).
|
||||
- `arbVertical`: oneof `'NTS_AEO' | 'SDIT_CISO' | 'TSI' | null`.
|
||||
- `arbUpload`: `{ report_date, vertical, new_count, recurring_count, resolved_count }` with non-negative integer counts.
|
||||
- `arbItem`: `{ hostname, team in ALLOWED_TEAMS, category in {Patching, Configuration, Vulnerability, Other}, vertical, status in {active, resolved} }`.
|
||||
- `arbScenario`: `{ uploads: arbUpload[], items: arbItem[] }`, where items reference uploads via `upload_id` and dates can collide.
|
||||
179
.kiro/specs/compliance-duplicate-chart-entries/tasks.md
Normal file
179
.kiro/specs/compliance-duplicate-chart-entries/tasks.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Five compliance code paths share the root cause "key by `compliance_uploads.id` instead of by `compliance_uploads.report_date`": `GET /trends`, `GET /top-recurring`, `GET /category-trend`, `GET /summary`, and the `compliance_snapshots` block inside `persistUpload()`. All fixes are contained to `backend/routes/compliance.js` and require no schema migration, no new column, and no frontend change.
|
||||
|
||||
The plan follows the bugfix workflow's exploratory methodology: a single property-based test file (`backend/__tests__/compliance-duplicate-chart-entries.property.test.js`) is written before any fix, with one test case per affected site demonstrating the bug condition, plus preservation cases observed on the unfixed code. Each fix is then implemented as its own task and verified by re-running the matching test case from the exploration suite. The plan ends with a regression checkpoint that re-runs the full preservation suite and the backend test suite.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1", "2"] },
|
||||
{ "id": 1, "tasks": ["3.1", "4.1", "5.1", "6.1", "7.1"] },
|
||||
{ "id": 2, "tasks": ["3.2", "4.2", "5.2", "6.2", "7.2"] },
|
||||
{ "id": 3, "tasks": ["8"] },
|
||||
{ "id": 4, "tasks": ["9"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Wave 0 establishes the test baseline: task 1 documents Property 1 (bug condition) failures on the unfixed code and task 2 captures Property 2 (preservation) baseline outputs. Wave 1 implements the five independent fixes in `backend/routes/compliance.js` (no inter-fix dependencies — each touches a different SQL statement). Wave 2 verifies each fix's slice of Property 1 now passes. Wave 3 re-runs the full Property 2 suite to confirm no regressions across the five sites. Wave 4 is the final checkpoint that runs the entire backend test suite.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Write bug condition exploration property test
|
||||
- **Property 1: Bug Condition** - Multi-Vertical Date Aggregation Across Five Compliance Sites
|
||||
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists across all five sites
|
||||
- **DO NOT attempt to fix the test or the code when it fails**
|
||||
- **NOTE**: This test encodes the expected behavior — it will validate the fixes when it passes after implementation
|
||||
- **GOAL**: Surface counterexamples that demonstrate the bug exists for each of the five affected code paths (`/trends`, `/top-recurring`, `/category-trend`, `/summary`, `persistUpload()` snapshot block)
|
||||
- **Scoped PBT Approach**: Scope the property to the canonical bug-condition fixture (`fixture_multi_vertical_single_date` from design.md) — three uploads for `2025-05-11`, one each for `NTS_AEO`, `SDIT_CISO`, `TSI`, with distinct counts and matching items per upload — this reproduces the original GitLab #12 scenario deterministically
|
||||
- Bug Condition (from design.md): `EXISTS report_date d WHERE COUNT(compliance_uploads WHERE report_date = d) > 1`
|
||||
- Create `backend/__tests__/compliance-duplicate-chart-entries.property.test.js` using `fast-check` and the existing pg pool mock pattern from `vcl-compliance-reporting.property.test.js`
|
||||
- Test case 1.A — `/trends` duplicate-date counterexample: seed three uploads for `2025-05-11` (verticals NTS_AEO/SDIT_CISO/TSI), call `GET /trends`, assert `response.trends.filter(t => t.report_date === '2025-05-11').length === 1` AND `new_count` for that date equals the sum of the three uploads' `new_count` values (likewise `recurring_count`, `resolved_count`, `total_active`, and per-team counts)
|
||||
- Test case 1.B — `/top-recurring` duplicate-bar counterexample: same fixture, call `GET /top-recurring`, assert exactly one waterfall entry per unique `report_date` AND the running invariant `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` holds for every `i` AND `entry[i].start === entry[i-1].end` for adjacent entries (with `entry[0].start === 0`)
|
||||
- Test case 1.C — `/category-trend` duplicate (date, category) counterexample: same fixture plus items tagged with two categories (`Patching` and `Configuration`), call `GET /category-trend`, assert `response.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Patching').length === 1` AND each entry's `count` equals the total `compliance_items` for that category across every upload sharing the date
|
||||
- Test case 1.D — `/summary` sibling-disclosure counterexample: same fixture (`2025-05-11` is the latest date), call `GET /summary`, assert either (a) `entries` is the merged view of all three uploads OR (b) `response.multi_vertical_uploads` is a non-empty array with `length === 2` listing the other two uploads' ids and verticals
|
||||
- Test case 1.E — `persistUpload()` cross-vertical contamination counterexample: pre-populate `compliance_items` with disjoint sets for two verticals (e.g., NTS_AEO has 100 active items, SDIT_CISO has 50 active items), call `persistUpload()` for a fresh SDIT_CISO upload, read back the `compliance_snapshots` row for the current month with `vertical = 'SDIT_CISO'`, assert `total_devices` reflects only SDIT_CISO items and is not inflated by NTS_AEO items
|
||||
- Wrap each test case in fast-check `fc.assert` against `arbScenario` from design.md (uploads with possibly colliding `report_date`, items referencing those uploads) so PBT also exercises larger random fixtures beyond the canonical 3-upload case
|
||||
- Add fixture builders in the test file matching the design's `fixture_multi_vertical_single_date` and `fixture_cross_vertical_items`
|
||||
- Run test on UNFIXED code: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js`
|
||||
- **EXPECTED OUTCOME**: All five test cases FAIL (this is correct — it proves each manifestation of the bug exists)
|
||||
- Document the counterexamples found (e.g., `/trends` returns 3 entries for `2025-05-11` instead of 1, `/summary` returns one upload's `summary_json` and silently drops the other two, `compliance_snapshots.total_devices` for SDIT_CISO equals 150 instead of 50)
|
||||
- Mark task complete when test is written, run, and the failures for all five test cases are documented
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11_
|
||||
|
||||
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||
- **Property 2: Preservation** - Single-Upload-Per-Date Behavior Unchanged Across Five Sites
|
||||
- **IMPORTANT**: Follow observation-first methodology — capture pre-fix outputs and assert equality post-fix
|
||||
- Bug Condition negation (from design.md): `FORALL report_date d, COUNT(compliance_uploads WHERE report_date = d) <= 1`
|
||||
- Add preservation test cases to `backend/__tests__/compliance-duplicate-chart-entries.property.test.js`
|
||||
- Observe baseline behavior on UNFIXED code using the design's preservation fixtures and capture exact response bodies (snapshot-test style)
|
||||
- Test case 2.A — Empty-state preservation (`fixture_empty`): no `compliance_uploads`, no `compliance_items`. Observe `GET /trends` returns `{ trends: [] }`, `GET /top-recurring` returns `{ waterfall: [] }`, `GET /category-trend` returns `{ categoryTrend: [] }`, `GET /summary` returns `{ entries: [], overall_scores: {}, upload: null }`. Capture and assert these exact shapes
|
||||
- Test case 2.B — Single AEO-legacy-upload preservation (`fixture_single_upload_aeo_legacy`): one upload with `vertical IS NULL`, `report_date = '2025-04-01'`, ~20 items across the four teams. Observe responses from all four read endpoints, capture them, and assert byte-for-byte equality
|
||||
- Test case 2.C — Multiple single-upload-per-date preservation (`fixture_single_upload_per_date`): five uploads on five distinct dates with varied `vertical` values. Observe responses from all four read endpoints and assert equality
|
||||
- Test case 2.D — `/summary` `team` query parameter preservation: with the latest upload present, assert `?team=STEAM` filters `entries` server-side AND `?team=OTHER` (non-`ALLOWED_TEAMS`) returns HTTP 400. Capture both responses
|
||||
- Test case 2.E — `persistUpload()` single-vertical-month preservation (`fixture_cross_vertical_items` reduced to one vertical): pre-populate `compliance_items` with rows from a single vertical only, run `persistUpload()` for that vertical, capture the resulting `compliance_snapshots` rows
|
||||
- Test case 2.F — `persistUpload()` snapshot error-path preservation: force a snapshot query failure (mock `pool.query` to reject on the snapshot statement only), assert the upload still commits and the error is logged but not surfaced (HTTP 200/201, no error response)
|
||||
- Property-based extension — Cross-endpoint preservation: use fast-check `arbScenario` constrained to scenarios where every `report_date` has exactly one upload row. Assert that for every generated scenario, all four endpoint responses on UNFIXED code match the captured-baseline shape and field-level equality holds (this generator covers the design's `fixture_pbt_generators.arbScenario` restricted to the non-bug-condition input space)
|
||||
- Run tests on UNFIXED code: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js`
|
||||
- **EXPECTED OUTCOME**: All preservation test cases PASS (this confirms the baseline behavior to preserve)
|
||||
- Mark task complete when tests are written, run, and passing on unfixed code
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
|
||||
|
||||
- [x] 3. Fix `/trends` — aggregate uploads and team counts by `report_date`
|
||||
|
||||
- [x] 3.1 Rewrite the `/trends` upload and team queries to group by `report_date`
|
||||
- In `backend/routes/compliance.js` `router.get('/trends', ...)` (around line 768), replace the `compliance_uploads` query with the `GROUP BY report_date` SQL from design.md Fix 1, summing `new_count`, `recurring_count`, `resolved_count`, and `(new_count + recurring_count) AS total_active`
|
||||
- Replace the per-team `compliance_items` query with the `JOIN compliance_uploads` + `GROUP BY cu.report_date, ci.team` form from design.md Fix 1
|
||||
- Change the `teamMap` keyed lookup from `teamMap[u.id]` to `teamMap[u.report_date]` and rebuild `trends` from the per-date upload rows
|
||||
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||
- _Expected_Behavior: GET /trends returns one entry per unique report_date with summed count fields and aggregated per-team counts (Property 1 from design)_
|
||||
- _Preservation: Single-upload-per-date dates produce identical responses; empty-data response remains `{ trends: [] }`; chart components require no changes_
|
||||
- _Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3_
|
||||
|
||||
- [x] 3.2 Verify the `/trends` portion of bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - `/trends` Returns One Entry Per Unique report_date
|
||||
- **IMPORTANT**: Re-run the SAME test case 1.A from task 1 — do NOT write a new test
|
||||
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/trends"`
|
||||
- **EXPECTED OUTCOME**: Test case 1.A PASSES (confirms `/trends` bug is fixed)
|
||||
- _Requirements: Property 1 (Validates 2.1, 2.2, 2.3) from design_
|
||||
|
||||
- [x] 4. Fix `/top-recurring` — aggregate uploads by `report_date` before passing to `computeWaterfall()`
|
||||
|
||||
- [x] 4.1 Rewrite the `/top-recurring` upload query to group by `report_date`
|
||||
- In `backend/routes/compliance.js` `router.get('/top-recurring', ...)` (around line 818), replace the query with the `GROUP BY report_date` SQL from design.md Fix 2, summing `new_count`, `recurring_count`, `resolved_count`
|
||||
- Leave `computeWaterfall()` unchanged — it already advances `start` correctly when fed one row per date; the fix is purely in the SQL
|
||||
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||
- _Expected_Behavior: GET /top-recurring returns one waterfall entry per unique report_date with summed deltas; running invariant `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` holds and `entry[i].start === entry[i-1].end` for adjacent entries (Property 2 from design)_
|
||||
- _Preservation: Single-upload-per-date waterfall is unchanged; empty-data response remains `{ waterfall: [] }`_
|
||||
- _Requirements: 2.4, 2.5, 3.4_
|
||||
|
||||
- [x] 4.2 Verify the `/top-recurring` portion of bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - `/top-recurring` Has One Bar Per Unique report_date With Correct Running Totals
|
||||
- **IMPORTANT**: Re-run the SAME test case 1.B from task 1 — do NOT write a new test
|
||||
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/top-recurring"`
|
||||
- **EXPECTED OUTCOME**: Test case 1.B PASSES (confirms `/top-recurring` bug is fixed and the running invariant holds)
|
||||
- _Requirements: Property 2 (Validates 2.4, 2.5) from design_
|
||||
|
||||
- [x] 5. Fix `/category-trend` — drop `cu.id` from `GROUP BY`
|
||||
|
||||
- [x] 5.1 Rewrite the `/category-trend` query to group by `(report_date, category)` only
|
||||
- In `backend/routes/compliance.js` `router.get('/category-trend', ...)` (around line 838), replace the query with the SQL from design.md Fix 3 — remove `cu.id` from the `GROUP BY` so grouping is by `(cu.report_date, COALESCE(ci.category, 'Unknown'))` only
|
||||
- Leave the response shape `{ categoryTrend: Array<{ report_date, category, count }> }` unchanged
|
||||
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||
- _Expected_Behavior: GET /category-trend returns one row per unique (report_date, category) pair with `count` equal to the total `compliance_items` for that category across every upload sharing the date (Property 3 from design)_
|
||||
- _Preservation: Single-upload-per-date rows are unchanged; empty-data response remains `{ categoryTrend: [] }`; total-conservation property holds across all dates (Property 6 from design)_
|
||||
- _Requirements: 2.6, 2.7, 3.5_
|
||||
|
||||
- [x] 5.2 Verify the `/category-trend` portion of bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - `/category-trend` Returns One Row Per (date, category)
|
||||
- **IMPORTANT**: Re-run the SAME test case 1.C from task 1 — do NOT write a new test
|
||||
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/category-trend"`
|
||||
- **EXPECTED OUTCOME**: Test case 1.C PASSES (confirms `/category-trend` bug is fixed)
|
||||
- _Requirements: Property 3 (Validates 2.6, 2.7) from design_
|
||||
|
||||
- [x] 6. Fix `/summary` — disclose sibling uploads for the latest date
|
||||
|
||||
- [x] 6.1 Add sibling-upload disclosure to the `/summary` response
|
||||
- In `backend/routes/compliance.js` `router.get('/summary', ...)` (around line 495), keep the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting the primary upload's `summary_json` (preserves legacy single-upload behavior per requirement 3.6)
|
||||
- After resolving `latestUpload`, run the second query from design.md Fix 4 to find sibling uploads sharing the same `report_date`: `SELECT id, vertical, uploaded_at FROM compliance_uploads WHERE report_date = $1 AND id != $2 ORDER BY id ASC`
|
||||
- Add `multi_vertical_uploads` to the response, populated with `siblings.map(s => ({ id, vertical, uploaded_at }))`; the field is `[]` when no siblings exist
|
||||
- Do not change the `team` query parameter handling, the `ALLOWED_TEAMS` HTTP 400 response, or the `entries`/`overall_scores`/`upload` shape
|
||||
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share the latest `report_date`_
|
||||
- _Expected_Behavior: GET /summary either merges sibling uploads' entries OR exposes a non-empty `multi_vertical_uploads` array identifying the other uploads for the same `report_date`; sibling uploads are never silently dropped (Property 4 from design)_
|
||||
- _Preservation: Single-upload-per-date `/summary` shape is unchanged (the `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback still runs); `team` query parameter still filters entries and rejects non-`ALLOWED_TEAMS` with HTTP 400; empty-data response remains `{ entries: [], overall_scores: {}, upload: null }`_
|
||||
- _Requirements: 2.8, 2.9, 3.6, 3.7_
|
||||
|
||||
- [x] 6.2 Verify the `/summary` portion of bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - `/summary` Does Not Silently Drop Sibling Uploads
|
||||
- **IMPORTANT**: Re-run the SAME test case 1.D from task 1 — do NOT write a new test
|
||||
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/summary"`
|
||||
- **EXPECTED OUTCOME**: Test case 1.D PASSES (confirms `/summary` discloses sibling uploads via `multi_vertical_uploads`)
|
||||
- _Requirements: Property 4 (Validates 2.8, 2.9) from design_
|
||||
|
||||
- [x] 7. Fix `persistUpload()` snapshot block — filter and group by `vertical`
|
||||
|
||||
- [x] 7.1 Rewrite the `verticalStats` query to filter by the upload's `vertical`
|
||||
- In `backend/routes/compliance.js` `persistUpload()` (lines 81–192), at the `verticalStats` query around line 157, capture the upload's `vertical` from the row returned by the `RETURNING id` insert (or accept it as a `persistUpload()` parameter)
|
||||
- Replace the `verticalStats` query with the SQL from design.md Fix 5: filter `WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1` and group by `(vertical, team)`. The `IS NOT DISTINCT FROM` operator handles the legacy `vertical IS NULL` case so AEO-only uploads keep their previous semantics
|
||||
- Leave the existing `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` mapping as-is so `compliance_snapshots` consumers (`/vcl/stats`) continue to read the same column shape; only the underlying counts change
|
||||
- Keep snapshot creation wrapped in the existing `try/catch` so a snapshot failure is logged and does not fail the upload commit
|
||||
- _Bug_Condition: isBugCondition for `persistUpload()` is `compliance_items` containing rows for verticals other than the upload's vertical — the unfiltered query inflates `total_devices`/`compliant`/`non_compliant`_
|
||||
- _Expected_Behavior: compliance_snapshots rows written by persistUpload() have `total_devices`, `compliant`, `non_compliant` derived only from compliance_items rows belonging to the snapshotted vertical; no item from another vertical contributes (Property 5 from design)_
|
||||
- _Preservation: Single-vertical months produce identical snapshot rows; the legacy `vertical IS NULL` AEO-only path is unchanged via `IS NOT DISTINCT FROM`; the snapshot try/catch error path is unchanged_
|
||||
- _Requirements: 2.10, 2.11, 3.8, 3.9_
|
||||
|
||||
- [x] 7.2 Verify the `persistUpload()` portion of bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - persistUpload() Snapshot Reflects Only the Snapshotted Vertical
|
||||
- **IMPORTANT**: Re-run the SAME test case 1.E from task 1 — do NOT write a new test
|
||||
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "persistUpload"`
|
||||
- **EXPECTED OUTCOME**: Test case 1.E PASSES (confirms snapshot rows are filtered to the snapshotted vertical only)
|
||||
- _Requirements: Property 5 (Validates 2.10, 2.11) from design_
|
||||
|
||||
- [x] 8. Verify preservation tests still pass after all five fixes
|
||||
- **Property 2: Preservation** - Single-Upload-Per-Date Behavior Unchanged
|
||||
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||
- Run preservation property tests from task 2: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "Preservation"`
|
||||
- Confirm all six preservation test cases (2.A–2.F) and the cross-endpoint property-based preservation extension still pass
|
||||
- **EXPECTED OUTCOME**: Tests PASS (confirms no regressions across the four read endpoints, the `/summary` `team` filter, the `persistUpload()` single-vertical-month path, and the snapshot error-path behavior)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
|
||||
|
||||
- [x] 9. Checkpoint — Ensure all tests pass
|
||||
- Run the full backend test suite: `npm run test:backend`
|
||||
- Confirm `compliance-duplicate-chart-entries.property.test.js` passes end-to-end (both Property 1 expected-behavior cases and Property 2 preservation cases)
|
||||
- Confirm pre-existing tests (`vcl-compliance-reporting.property.test.js`, `vcl-aggregated-burndown.property.test.js`, `vcl-aggregated-burndown.test.js`, `vcl-compliance-reporting.test.js`, `fp-submissions-cleanup.test.js`, etc.) still pass — none of these should be affected since the fix is contained to read queries and one snapshot write query in `compliance.js`
|
||||
- Spot-check the integration scenarios from design.md "Integration Tests": upload three xlsx files for the same `report_date` via `POST /preview` + `POST /commit`, then call `/trends`, `/top-recurring`, `/category-trend`, `/summary` and verify aggregated/disclosed responses; call `/vcl/stats` and verify per-vertical `compliance_pct` is correct
|
||||
- Ensure all tests pass, ask the user if questions arise
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- All five fixes are contained to `backend/routes/compliance.js`. No database migration, no new column, no frontend change.
|
||||
- The Property 1 / Property 2 task numbering follows the bugfix workflow convention so the IDE hover-status indicator can track exploration tests against expected-behavior verification. Task 1 is the single Property 1 source; tasks 3.2 / 4.2 / 5.2 / 6.2 / 7.2 each re-run the relevant slice of Property 1 (NOT new tests) to confirm the matching fix lands correctly. Task 2 is the single Property 2 source; task 8 re-runs Property 2 in full to confirm no regressions.
|
||||
- The implementation tasks (3 through 7) are independent at the SQL level. Each can be reviewed and merged without waiting on the others, as long as task 1 and task 2 have run on the unfixed code first.
|
||||
- The `_Bug_Condition`, `_Expected_Behavior`, and `_Preservation` annotations on each fix sub-task reference the formal pseudocode in `design.md` Glossary and Bug Details sections.
|
||||
- `_Requirements: X.Y_` annotations cite clauses in `bugfix.md` Bug Analysis.
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "7a1ca671-3974-49b1-8e83-023077e758d5", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||
65
.kiro/specs/compliance-duplicate-failing-metrics/bugfix.md
Normal file
65
.kiro/specs/compliance-duplicate-failing-metrics/bugfix.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Bugfix Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Several Compliance backend endpoints select from `compliance_items` without scoping to a single vertical, so when the same `(hostname, metric_id)` exists in both a legacy `vertical IS NULL` upload and a multi-vertical `vertical = 'NTS_AEO'` upload, the duplicate rows distort the response. The originally reported symptom is on the device-level violation view: hostname `STEAM-INTERSIGHT` (IP 172.16.30.40) shows metric `7.1.1` listed twice in its failing metrics list (GitLab issue #13, reported by nkapur). Investigation found the same duplication pattern in additional endpoints that drive per-team and per-vertical reporting.
|
||||
|
||||
This spec covers the full class of "duplicate `(hostname, metric_id)` rows across verticals" bugs in `backend/routes/compliance.js`. The affected surfaces are:
|
||||
|
||||
1. `GET /items` — failing metrics list per device (originally reported)
|
||||
2. `GET /items/:hostname` — device detail metrics array (originally reported)
|
||||
3. `persistUpload()` `compliance_snapshots` creation block — per-vertical compliant/non-compliant counts
|
||||
4. `GET /vcl/stats` — heavy-hitters team counts, per-team device totals, and forecast-burndown row counts
|
||||
5. `GET /mttr` — per-team aging bucket counts
|
||||
|
||||
**Root Cause:** Each affected query selects from `compliance_items` either with a vertical filter that admits both legacy and multi-vertical rows (`vertical IS NULL OR vertical = 'NTS_AEO'`) or with no vertical filter at all. Some queries dedupe at the hostname level via `COUNT(DISTINCT hostname)`, which protects against per-team device totals being inflated, but does not protect aggregations that depend on `(hostname, metric_id)` uniqueness, status uniqueness per hostname, or team uniqueness per hostname. The `groupByHostname` helper and the `/items/:hostname` query likewise have no deduplication at all, so every duplicate row becomes a duplicate metric in the response.
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Current Behavior (Defect)
|
||||
|
||||
1.1 WHEN a device has compliance_items rows for the same (hostname, metric_id) pair across multiple verticals (e.g., one row with `vertical IS NULL` and another with `vertical = 'NTS_AEO'`) THEN the `/items` endpoint returns both rows and the `groupByHostname` function adds the same metric_id to `failing_metrics` multiple times
|
||||
|
||||
1.2 WHEN the `/items/:hostname` detail endpoint is called for a device that has compliance_items rows across multiple verticals THEN the system returns duplicate metric entries in the `metrics` array because the query has no vertical filter or deduplication
|
||||
|
||||
1.3 WHEN the ComplianceDetailPanel renders the metrics array for a device with duplicate entries THEN the same metric_id chip appears multiple times in the "Failing Metrics" section, confusing users about the actual number of distinct violations
|
||||
|
||||
1.4 WHEN `persistUpload()` builds per-team rows for `compliance_snapshots` and the same hostname has compliance_items rows in both a legacy `vertical IS NULL` upload and an `vertical = 'NTS_AEO'` upload with different statuses (e.g., `active` in one vertical and `resolved` in the other) THEN the snapshot query counts that hostname in BOTH the `compliant` and `non_compliant` columns for the team, inflating per-team totals and producing a row where `compliant + non_compliant > total_devices`
|
||||
|
||||
1.5 WHEN `/vcl/stats` computes the heavy-hitters table and per-team totals and the same hostname has rows in two verticals where the `team` column differs (e.g., `team = 'STEAM'` in the legacy row and `team = 'ACCESS-ENG'` in the NTS_AEO row) THEN the `COUNT(DISTINCT hostname)` aggregate counts the hostname under both team groups, double-counting the device across teams
|
||||
|
||||
1.6 WHEN `/vcl/stats` builds the forecast-burndown for a team by selecting `resolution_date` rows from compliance_items without `DISTINCT` AND the same `(hostname, metric_id)` has duplicate active rows across verticals, both with a non-null `resolution_date` THEN the forecast row count is inflated and the `blockers = teamNonCompliant - forecastItems.length` calculation can go negative or report a misleadingly low blocker count
|
||||
|
||||
1.7 WHEN `/mttr` selects `seen_count, team` from active compliance_items without deduplication AND the same (hostname, metric_id) has duplicate active rows across verticals THEN each duplicate row is bucketed independently in `bucketAgingItems`, inflating per-team aging totals for that team
|
||||
|
||||
### Expected Behavior (Correct)
|
||||
|
||||
2.1 WHEN a device has compliance_items rows for the same (hostname, metric_id) pair across multiple verticals THEN the `/items` endpoint SHALL return only one entry per unique (hostname, metric_id) combination in the `failing_metrics` array, using the row with the highest `seen_count` or most recent `upload_id` as the representative
|
||||
|
||||
2.2 WHEN the `/items/:hostname` detail endpoint is called for a device with rows across multiple verticals THEN the system SHALL return only one metric entry per unique (metric_id, status) combination, preferring the row with the highest `seen_count` or most recent data
|
||||
|
||||
2.3 WHEN the ComplianceDetailPanel renders the metrics for a device THEN each distinct metric_id SHALL appear exactly once in the "Failing Metrics" section regardless of how many underlying compliance_items rows exist for that metric across verticals
|
||||
|
||||
2.4 WHEN `persistUpload()` writes a per-team row to `compliance_snapshots` THEN the system SHALL count each unique hostname at most once across the (compliant, non_compliant) columns for that team, classifying a hostname as `non_compliant` if it has any active row in any vertical for the team and `compliant` only if all of its rows for the team are resolved, so that `compliant + non_compliant ≤ total_devices` always holds
|
||||
|
||||
2.5 WHEN `/vcl/stats` computes heavy-hitters and per-team totals THEN the system SHALL count each unique hostname under exactly one team — the team derived from the most recent (or otherwise canonical) compliance_items row for that hostname across all verticals — so that summing `non_compliant` across teams equals the total non-compliant device count
|
||||
|
||||
2.6 WHEN `/vcl/stats` builds the forecast-burndown for a team THEN the forecast row count SHALL be deduplicated by `(hostname, metric_id)` so that cross-vertical duplicate rows contribute at most one entry per unique violation, and `blockers = teamNonCompliant - dedupedForecastCount` SHALL never be negative
|
||||
|
||||
2.7 WHEN `/mttr` computes aging buckets per team THEN each unique (hostname, metric_id) active violation SHALL be bucketed exactly once using a single representative `seen_count` value, regardless of how many duplicate rows exist across verticals
|
||||
|
||||
### Unchanged Behavior (Regression Prevention)
|
||||
|
||||
3.1 WHEN a device has multiple distinct failing metric_ids (e.g., 7.1.1 and 7.2.1) THEN the system SHALL CONTINUE TO display each distinct metric_id separately in the failing metrics list
|
||||
|
||||
3.2 WHEN a device has both active and resolved entries for the same metric_id THEN the system SHALL CONTINUE TO show the metric in the appropriate section (active or resolved) based on its status
|
||||
|
||||
3.3 WHEN only one compliance upload exists per vertical for a device (no cross-vertical duplication) THEN the system SHALL CONTINUE TO display metrics unchanged with correct seen_count, first_seen, and last_seen values
|
||||
|
||||
3.4 WHEN the `/items` list endpoint is called with a team filter THEN the system SHALL CONTINUE TO return all devices for that team with their correct (now deduplicated) failing metrics and accurate seen_count values
|
||||
|
||||
3.5 WHEN `persistUpload()` builds `compliance_snapshots` for a team whose devices exist in only one vertical THEN per-team `total_devices`, `compliant`, `non_compliant`, and `compliance_pct` SHALL CONTINUE TO match their pre-fix values
|
||||
|
||||
3.6 WHEN `/vcl/stats` computes overall stats, donut categorization, heavy-hitters, per-team totals, and forecast-burndown for devices that exist in only one vertical THEN every field in the response SHALL CONTINUE TO match its pre-fix value
|
||||
|
||||
3.7 WHEN `/mttr` computes aging buckets for teams whose active items exist in only one vertical THEN per-team and total bucket counts SHALL CONTINUE TO match their pre-fix values
|
||||
511
.kiro/specs/compliance-duplicate-failing-metrics/design.md
Normal file
511
.kiro/specs/compliance-duplicate-failing-metrics/design.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# Compliance Duplicate Failing Metrics Bugfix Design
|
||||
|
||||
## Overview
|
||||
|
||||
Five compliance endpoints in `backend/routes/compliance.js` (`GET /items`, `GET /items/:hostname`, `GET /vcl/stats`, `GET /mttr`) and the `compliance_snapshots` block inside `persistUpload()` all share the same root cause: each one reads `compliance_items` with either no vertical filter or a filter that admits both legacy `vertical IS NULL` rows and multi-vertical `vertical = 'NTS_AEO'` rows, but does not deduplicate on `(hostname, metric_id)`. When the same `(hostname, metric_id)` pair exists in two verticals (the documented multi-vertical workflow), the duplicate row distorts the response — it duplicates a metric chip in the UI, double-counts a device across teams, inflates aging buckets, inflates forecast row counts, and (in the snapshot block) lets a single hostname appear in both `compliant` and `non_compliant` columns of the same `(snapshot_month, vertical)` row.
|
||||
|
||||
The fix is uniform across endpoints: dedupe at the SQL layer using `DISTINCT ON (hostname, metric_id)` with a deterministic `ORDER BY` (highest `seen_count`, then most recent `upload_id`) so each unique violation contributes exactly one row to the aggregation, and rewrite the snapshot query so each hostname is classified by its `MIN(status)` (active wins over resolved) inside a CTE before the count. The `groupByHostname` helper and the `/items/:hostname` response builder also gain a defensive in-memory dedupe keyed by `metric_id` (or `(metric_id, status)` for the detail endpoint), so a duplicate row that slips through any unforeseen code path still cannot duplicate a chip in the UI.
|
||||
|
||||
The implementation is intentionally minimal: each fix changes a single SQL statement and (for two endpoints) a small JavaScript loop. No schema migration, no new column, no frontend change. The frontend `ComplianceDetailPanel` already keys metrics on `metric_id` for chip rendering and will render correctly once the API stops returning duplicate rows.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Bug_Condition (C)**: The condition that triggers the bug — two or more rows in `compliance_items` share the same `(hostname, metric_id)` pair across verticals (i.e., one row with `vertical IS NULL` and another with `vertical = 'NTS_AEO'`, or two non-null verticals that both pass an endpoint's `WHERE` clause).
|
||||
- **Property (P)**: The desired behavior when C holds — each affected endpoint returns or counts each `(hostname, metric_id)` exactly once, using the row with the highest `seen_count` (with most recent `upload_id` as tiebreak) as the representative.
|
||||
- **Preservation**: Behavior on rows where `(hostname, metric_id)` is unique across verticals, on the empty-data response shape, and on unrelated query parameters (e.g., `team` and `status` on `/items`) — all must be byte-for-byte unchanged.
|
||||
- **vertical**: `TEXT` column on `compliance_items` and `compliance_uploads` identifying which xlsx (NTS_AEO, SDIT_CISO, TSI) the row originated from. `NULL` indicates a legacy AEO-only upload. The bug class is specifically about rows that share `(hostname, metric_id)` but differ in `vertical`.
|
||||
- **groupByHostname(rows, noteHostnames)**: Helper in `backend/routes/compliance.js` (lines ~213–230) that flattens a list of joined `compliance_items` rows into one device object per hostname, pushing each row's `metric_id` onto `failing_metrics`. It performs no deduplication.
|
||||
- **bucketAgingItems(items)**: Helper in `backend/routes/compliance.js` (lines ~234–254) that places each item into one of four `seen_count` buckets per team. It iterates rows directly, so duplicate rows produce double-counted buckets.
|
||||
- **persistUpload()**: Function in `backend/routes/compliance.js` (lines ~81–192) that writes a parsed upload to the DB and then writes per-vertical rows into `compliance_snapshots`. The snapshot query at the end of this function counts hostnames per team using `COUNT(DISTINCT CASE WHEN status = 'X' THEN hostname END)`, which double-counts a hostname into both the `compliant` and `non_compliant` columns when the hostname has both `active` and `resolved` rows across verticals.
|
||||
- **representative row**: For a duplicated `(hostname, metric_id)`, the row chosen by `DISTINCT ON (hostname, metric_id) ... ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`. This is deterministic and aligns with the "use the row with the highest `seen_count` or most recent `upload_id`" rule from the requirements.
|
||||
|
||||
## Bug Details
|
||||
|
||||
### Bug Condition
|
||||
|
||||
The bug manifests when two or more rows in `compliance_items` share the same `(hostname, metric_id)` pair across different `vertical` values. This happens whenever the same device fails the same metric in more than one vertical's xlsx — including the originally reported case where a legacy `vertical IS NULL` AEO upload and a newer `vertical = 'NTS_AEO'` multi-vertical upload both contain `STEAM-INTERSIGHT` failing `7.1.1`.
|
||||
|
||||
**Formal Specification:**
|
||||
```
|
||||
FUNCTION isBugCondition(items)
|
||||
INPUT: items — list of compliance_items rows
|
||||
OUTPUT: boolean
|
||||
|
||||
// The bug condition is triggered for any (hostname, metric_id) pair with more than one row
|
||||
GROUP items BY (hostname, metric_id) INTO groups
|
||||
RETURN EXISTS group IN groups WHERE COUNT(group) > 1
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
For a single endpoint response to be considered buggy, the API output must additionally fail one of the following invariants (the per-endpoint manifestation of the same root cause):
|
||||
|
||||
```
|
||||
FUNCTION isBuggyResponse(endpoint, response)
|
||||
CASE endpoint OF
|
||||
'/items': RETURN EXISTS device IN response.devices WHERE
|
||||
COUNT(device.failing_metrics) != COUNT(DISTINCT metric_id IN device.failing_metrics)
|
||||
'/items/:hostname': RETURN EXISTS (metric_id, status) WITH COUNT(*) > 1 IN response.metrics
|
||||
'/vcl/stats heavy_hitters': RETURN SUM(hh.non_compliant FOR hh IN response.heavy_hitters) >
|
||||
COUNT(DISTINCT hostname WHERE has_active_violation)
|
||||
'/vcl/stats forecast': RETURN response.vertical_breakdown[i].blockers < 0
|
||||
FOR SOME i WITH duplicated (hostname, metric_id) AND non-null resolution_date
|
||||
'/mttr': RETURN SUM(b.total FOR b IN response.aging) >
|
||||
COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')
|
||||
'persistUpload snapshot': RETURN EXISTS row IN compliance_snapshots WHERE
|
||||
compliant + non_compliant > total_devices
|
||||
END CASE
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
The originally reported case (GitLab issue #13, `STEAM-INTERSIGHT` / 172.16.30.40 / metric `7.1.1`) and the four sibling manifestations:
|
||||
|
||||
- **`/items`** — `STEAM-INTERSIGHT` has two active rows for `metric_id = '7.1.1'` (one with `vertical IS NULL`, one with `vertical = 'NTS_AEO'`). The handler's `WHERE` clause admits both via `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')`, and `groupByHostname()` does an unconditional `dev.failing_metrics.push(...)` per row. The device's `failing_metrics` array contains two entries with `metric_id = '7.1.1'`. Expected: one entry per unique `metric_id`.
|
||||
|
||||
- **`/items/:hostname`** — Same hostname, but the detail query has no vertical filter at all (just `WHERE ci.hostname = $1`). Both rows come back, the response builder maps `metricRows.map(...)` over both, and the frontend's `MetricChip` renders `7.1.1` twice in the "Failing Metrics" section. Expected: one entry per `(metric_id, status)` pair.
|
||||
|
||||
- **`/vcl/stats` heavy-hitters and per-team totals** — A device whose `team` differs between verticals (e.g., `STEAM` in the legacy row and `ACCESS-ENG` in the NTS_AEO row) is counted under both teams by `COUNT(DISTINCT hostname) ... GROUP BY team`. The sum across `heavy_hitters[*].non_compliant` exceeds the dashboard's `stats.non_compliant`. Expected: the hostname is assigned to exactly one team — the team from its representative row — and the per-team sums reconcile with the global non-compliant count.
|
||||
|
||||
- **`/vcl/stats` forecast-burndown** — For a team with one duplicated `(hostname, metric_id)` whose `resolution_date` is non-null in both rows, the forecast query `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND team = $1 AND resolution_date IS NOT NULL` returns two rows. `forecastItems.length` is 2, but `teamNonCompliant` (from the de-team-counted DISTINCT-hostname query) is 1, so `blockers = 1 - 2 = -1`. The route then clamps to 0, hiding the underlying inconsistency. Expected: forecast is deduped by `(hostname, metric_id)` so the count matches `teamNonCompliant`'s scoping and `blockers` is non-negative.
|
||||
|
||||
- **`/mttr`** — Same duplicated `(hostname, metric_id)`. `bucketAgingItems()` receives both rows, increments the bucket twice. The team total for `STEAM` (or whichever team appears in the duplicate row) is inflated. Expected: each unique `(hostname, metric_id)` contributes to exactly one bucket using a single representative `seen_count`.
|
||||
|
||||
- **Edge case — `persistUpload()` snapshot** — A hostname has two rows: one with `status = 'resolved'` (legacy vertical) and one with `status = 'active'` (NTS_AEO vertical). The snapshot query
|
||||
```sql
|
||||
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END) AS compliant,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END) AS non_compliant
|
||||
```
|
||||
counts the hostname once in `compliant` and once in `non_compliant`, so `compliant + non_compliant > total_devices`. Expected: the hostname is classified by its worst-case status (active wins over resolved) inside a CTE so it appears in exactly one column.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Preservation Requirements
|
||||
|
||||
**Unchanged Behaviors:**
|
||||
- Rows where `(hostname, metric_id)` is unique across verticals: every endpoint returns the same numbers, in the same shape, in the same order as before the fix.
|
||||
- Empty-data responses: `/items` returns `{ devices: [], team, status }`, `/items/:hostname` returns `404` for unknown hostnames, `/vcl/stats` returns its zero-state shape, `/mttr` returns `{ aging: [] }`.
|
||||
- `/items` `team` and `status` query parameters: still validated against `ALLOWED_TEAMS` and `['active', 'resolved']`, still reject invalid values with HTTP 400.
|
||||
- `/items` `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate: kept as-is so AEO-only legacy data continues to surface alongside NTS_AEO multi-vertical data. The fix only adds dedup on top, it does not narrow the set of admitted verticals.
|
||||
- `/items/:hostname` ordering by `status DESC, metric_id`: unchanged so `active` metrics remain listed before `resolved` ones.
|
||||
- `/vcl/stats` donut categorization (`blocked` / `in_progress` by `MAX(resolution_date)` per hostname): already deduped by `GROUP BY hostname` and stays unchanged.
|
||||
- `/vcl/stats` global `stats.compliant` / `stats.non_compliant`: already use `COUNT(DISTINCT hostname)` and stay unchanged.
|
||||
- Frontend components (`ComplianceDetailPanel`, `ComplianceCharts`, `CompliancePage`): no changes required. They already render one chip per `metric_id`; the bug was purely an upstream data issue.
|
||||
- `persistUpload()` error handling: snapshot creation remains wrapped in `try/catch` that logs but does not fail the upload commit.
|
||||
|
||||
**Scope:**
|
||||
All endpoint inputs that do not involve cross-vertical duplicate `(hostname, metric_id)` rows must be byte-for-byte identical to the pre-fix output. The fix only changes what happens when two or more `compliance_items` rows share `(hostname, metric_id)` across verticals.
|
||||
|
||||
## Hypothesized Root Cause
|
||||
|
||||
All five sites have the same shape of bug — missing dedup on `(hostname, metric_id)` after the multi-vertical migration admitted two-row scenarios — but with slightly different mechanics. Listing them explicitly so the test plan can confirm or refute each one:
|
||||
|
||||
1. **`/items` — `groupByHostname()` pushes every row.** The handler's `WHERE` clause `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` admits both verticals' rows. `groupByHostname()` then iterates each row and unconditionally calls `dev.failing_metrics.push({ metric_id, ... })`. There is no `Set`/`Map` keyed on `metric_id`, so a duplicate row produces a duplicate metric in the array.
|
||||
|
||||
2. **`/items/:hostname` — no vertical filter, no dedup.** The detail query is `WHERE ci.hostname = $1` with no `vertical` predicate at all, so every vertical's row for that hostname is returned. The response builder does `metricRows.map(r => ({ ...r }))` — there is no dedup step. Every duplicate row becomes a duplicate entry in `metrics`.
|
||||
|
||||
3. **`/vcl/stats` heavy-hitters and per-team totals — team chosen per row, not per hostname.** The `GROUP BY team ... COUNT(DISTINCT hostname)` query is correct for "how many distinct hostnames does each team see," but a hostname with rows under two different teams (because `team` differs across verticals) is counted in both groups. The dashboard's global `stats.non_compliant` (a single `COUNT(DISTINCT hostname)` with no team scoping) does not match `SUM(heavy_hitters[*].non_compliant)`.
|
||||
|
||||
4. **`/vcl/stats` forecast-burndown — duplicate rows inflate forecast count.** The query `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND team = $1 AND resolution_date IS NOT NULL` returns one row per `compliance_items` row, not one row per `(hostname, metric_id)`. The downstream `blockers = teamNonCompliant - forecastItems.length` calculation can go negative because `forecastItems.length` is inflated relative to the deduped `teamNonCompliant`.
|
||||
|
||||
5. **`/mttr` — `bucketAgingItems()` iterates rows directly.** The query `SELECT seen_count, team FROM compliance_items WHERE status = 'active'` returns every active row, and `bucketAgingItems()` does `for (const item of items) { buckets[label].total += 1; ... }`. There is no `Set` keyed on `(hostname, metric_id)`, so each duplicate row increments its bucket twice.
|
||||
|
||||
6. **`persistUpload()` snapshot — `CASE WHEN status =` double-counts.** The snapshot query
|
||||
```sql
|
||||
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END) AS compliant,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END) AS non_compliant
|
||||
```
|
||||
counts a hostname in `compliant` if any of its rows is `resolved` AND in `non_compliant` if any of its rows is `active`. With duplicate rows that disagree on status across verticals, the same hostname lands in both columns.
|
||||
|
||||
The common structural cause is that the multi-vertical migration (`add_vcl_multi_vertical.js`) added a `vertical` column to `compliance_items` but did not retrofit existing read queries — which assumed `(hostname, metric_id)` was effectively a unique key — to either dedupe explicitly or scope to a single vertical.
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
Property 1: Bug Condition — `/items` failing_metrics array contains at most one entry per metric_id
|
||||
|
||||
_For any_ set of `compliance_items` rows including cross-vertical duplicates of `(hostname, metric_id)`, the response from `GET /items` SHALL contain, for every device in `response.devices`, exactly one entry per unique `metric_id` in `device.failing_metrics`. Formally: `device.failing_metrics.length == new Set(device.failing_metrics.map(m => m.metric_id)).size`.
|
||||
|
||||
**Validates: Requirements 2.1**
|
||||
|
||||
Property 2: Bug Condition — `/items/:hostname` returns one metric per (metric_id, status)
|
||||
|
||||
_For any_ device with cross-vertical duplicate `(hostname, metric_id)` rows, the response from `GET /items/:hostname` SHALL contain exactly one entry per unique `(metric_id, status)` pair in `response.metrics`. Each entry's `seen_count` SHALL equal the maximum `seen_count` across the duplicate rows for that pair.
|
||||
|
||||
**Validates: Requirements 2.2, 2.3**
|
||||
|
||||
Property 3: Bug Condition — `/vcl/stats` per-team device counts equal COUNT(DISTINCT hostname)
|
||||
|
||||
_For any_ set of `compliance_items` rows including cross-vertical duplicates where a hostname's `team` differs across verticals, the response from `GET /vcl/stats` SHALL satisfy `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant`. Each hostname SHALL be assigned to exactly one team — the team from its representative row (highest `seen_count`, then most recent `upload_id`) — regardless of how many verticals contain rows for that hostname.
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
Property 4: Bug Condition — `/vcl/stats` forecast-burndown is deduped by (hostname, metric_id) and blockers is non-negative
|
||||
|
||||
_For any_ team with cross-vertical duplicate `(hostname, metric_id)` rows where both rows have a non-null `resolution_date`, the deduped forecast row count for that team SHALL contribute at most one entry per unique `(hostname, metric_id)`, and `blockers = teamNonCompliant - dedupedForecastCount` SHALL be `>= 0` (no clamp required to satisfy the invariant).
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
|
||||
Property 5: Bug Condition — `/mttr` aging buckets count each unique (hostname, metric_id) exactly once
|
||||
|
||||
_For any_ set of active `compliance_items` rows including cross-vertical duplicates, the response from `GET /mttr` SHALL satisfy `SUM(aging[*].total) == COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')`. Each unique `(hostname, metric_id)` SHALL be bucketed exactly once using a single representative `seen_count`.
|
||||
|
||||
**Validates: Requirements 2.7**
|
||||
|
||||
Property 6: Bug Condition — `persistUpload()` snapshot rows satisfy compliant + non_compliant <= total_devices
|
||||
|
||||
_For any_ `persistUpload()` invocation, every row written into `compliance_snapshots` for the current `(snapshot_month, vertical)` pair SHALL satisfy `compliant + non_compliant <= total_devices`. A hostname with both `active` and `resolved` rows for the same team SHALL be classified as `non_compliant` (active wins over resolved) and SHALL appear in exactly one of the two columns.
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
Property 7: Preservation — non-duplicated rows are unchanged
|
||||
|
||||
_For any_ set of `compliance_items` rows where every `(hostname, metric_id)` is unique across verticals, the responses from `/items`, `/items/:hostname`, `/vcl/stats`, `/mttr`, and the `compliance_snapshots` rows written by `persistUpload()` SHALL be identical to the pre-fix output for the same input. The fix SHALL NOT change behavior on the unique-`(hostname, metric_id)` case.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7**
|
||||
|
||||
Property 8: Preservation — `seen_count`, `first_seen`, and `last_seen` are correctly aggregated for the representative row
|
||||
|
||||
_For any_ duplicated `(hostname, metric_id)`, the surviving entry SHALL carry `seen_count = MAX(seen_count)` across the duplicates, `first_seen = MIN(first_seen)` across the duplicates, and `last_seen = MAX(last_seen)` across the duplicates. Active/resolved status separation SHALL be preserved (a metric still appears in the `active` section if any of its duplicate rows is `active`).
|
||||
|
||||
**Validates: Requirements 3.2, 3.3**
|
||||
|
||||
## Fix Implementation
|
||||
|
||||
### Changes Required
|
||||
|
||||
All changes are in `backend/routes/compliance.js`. No schema migration, no new column, no frontend change. SQL-level dedup is preferred wherever possible because it avoids materialising duplicates into Node memory and centralises the representative-row policy at the data layer.
|
||||
|
||||
#### Fix 1: `GET /items` — `DISTINCT ON (hostname, metric_id)` in SQL, defensive dedupe in `groupByHostname`
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `router.get('/items', ...)` (around line 535) and helper `groupByHostname` (around line 213)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Rewrite the items query to use `DISTINCT ON (hostname, metric_id)` so each unique violation contributes exactly one row, choosing the representative row by highest `seen_count` and most recent `upload_id`:
|
||||
```sql
|
||||
SELECT DISTINCT ON (ci.hostname, ci.metric_id)
|
||||
ci.hostname, ci.ip_address, ci.device_type, ci.team,
|
||||
ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
|
||||
fu.report_date AS first_seen,
|
||||
lu.report_date AS last_seen,
|
||||
ru.report_date AS resolved_on
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.team = $1
|
||||
AND ci.status = $2
|
||||
AND (ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')
|
||||
ORDER BY ci.hostname, ci.metric_id, ci.seen_count DESC, ci.upload_id DESC
|
||||
```
|
||||
The `ORDER BY` lead expressions match the `DISTINCT ON` columns; the trailing expressions select the representative.
|
||||
2. Add a defensive in-memory dedupe in `groupByHostname()` keyed on `metric_id` per device, so any future code path that bypasses the SQL dedupe still cannot duplicate a chip:
|
||||
```javascript
|
||||
function groupByHostname(rows, noteHostnames) {
|
||||
const deviceMap = {};
|
||||
for (const row of rows) {
|
||||
if (!deviceMap[row.hostname]) {
|
||||
deviceMap[row.hostname] = {
|
||||
hostname: row.hostname, ip_address: row.ip_address || '', device_type: row.device_type || '',
|
||||
team: row.team || '', status: row.status, failing_metrics: [],
|
||||
_seenMetricIds: new Set(),
|
||||
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
||||
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
||||
has_notes: noteHostnames.has(row.hostname),
|
||||
};
|
||||
}
|
||||
const dev = deviceMap[row.hostname];
|
||||
if (!dev._seenMetricIds.has(row.metric_id)) {
|
||||
dev._seenMetricIds.add(row.metric_id);
|
||||
dev.failing_metrics.push({ metric_id: row.metric_id, metric_desc: row.metric_desc || '', category: row.category || '' });
|
||||
}
|
||||
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
|
||||
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
|
||||
}
|
||||
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
|
||||
}
|
||||
```
|
||||
The `_seenMetricIds` set is stripped before the device is returned so the response shape is unchanged.
|
||||
|
||||
#### Fix 2: `GET /items/:hostname` — `DISTINCT ON (metric_id, status)` in SQL
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `router.get('/items/:hostname', ...)` (around line 575)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Rewrite the metrics query to dedupe on `(metric_id, status)` so a metric that appears as both `active` and `resolved` is preserved (one row each), but cross-vertical duplicates of the same `(metric_id, status)` collapse to one:
|
||||
```sql
|
||||
SELECT DISTINCT ON (ci.metric_id, ci.status)
|
||||
ci.metric_id, ci.metric_desc, ci.category, ci.status,
|
||||
ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
|
||||
ci.resolution_date, ci.remediation_plan,
|
||||
fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at,
|
||||
lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at,
|
||||
ru.report_date AS resolved_on
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.hostname = $1
|
||||
ORDER BY ci.metric_id, ci.status, ci.seen_count DESC, ci.upload_id DESC
|
||||
```
|
||||
2. The existing post-query sort (`status DESC` for grouping, then `metric_id`) is rebuilt in JavaScript on the deduped result to preserve the response ordering:
|
||||
```javascript
|
||||
metricRows.sort((a, b) => {
|
||||
if (a.status !== b.status) return b.status.localeCompare(a.status); // 'resolved' < 'active' alphabetically; we want 'active' first
|
||||
return a.metric_id.localeCompare(b.metric_id);
|
||||
});
|
||||
```
|
||||
(Note: the original SQL used `ORDER BY ci.status DESC, ci.metric_id`, which placed `resolved` before `active` because of `DESC` on the alphabetic order. The post-fix sort reproduces that exact ordering on the deduped rows.)
|
||||
3. The `identity` lookup `metricRows.find(r => r.status === 'active') || metricRows[0]` is unchanged — it still picks the active representative for `ip_address`, `device_type`, `team`, `resolution_date`, and `remediation_plan`.
|
||||
|
||||
#### Fix 3: `GET /vcl/stats` heavy-hitters and per-team totals — dedupe to one team per hostname via CTE
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `router.get('/vcl/stats', ...)` (around line 990, the `teamRows` query)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Replace the heavy-hitters query with a CTE that first picks one representative row per hostname (using the same `DISTINCT ON (hostname)` policy: highest `seen_count`, then most recent `upload_id`), then groups by the representative's team:
|
||||
```sql
|
||||
WITH device_team AS (
|
||||
SELECT DISTINCT ON (hostname)
|
||||
hostname,
|
||||
COALESCE(team, 'Unknown') AS team,
|
||||
resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||
)
|
||||
SELECT team,
|
||||
COUNT(DISTINCT hostname)::int AS non_compliant,
|
||||
MAX(resolution_date) AS compliance_date
|
||||
FROM device_team
|
||||
GROUP BY team
|
||||
ORDER BY COUNT(DISTINCT hostname) DESC
|
||||
```
|
||||
Because the CTE already collapses each hostname to one row, `COUNT(DISTINCT hostname)` per team is equivalent to `COUNT(*)` per team, but `DISTINCT` is kept for defensive symmetry.
|
||||
2. The dashboard's existing `stats.non_compliant` query (also `COUNT(DISTINCT hostname)` over all active rows, no team scoping) is unchanged — but now the property `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant` holds because both numerators and denominators agree on "one hostname, one team."
|
||||
3. The per-team-total query inside the `for (const teamRow of teamRows)` loop (`SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $1`) is similarly rewritten to use the same `device_team`-style CTE so the team's `total_devices` matches the team's `non_compliant`. The simplest form:
|
||||
```sql
|
||||
WITH device_team AS (
|
||||
SELECT DISTINCT ON (hostname)
|
||||
hostname,
|
||||
COALESCE(team, 'Unknown') AS team
|
||||
FROM compliance_items
|
||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||
)
|
||||
SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1
|
||||
```
|
||||
Note: the loop runs once per team, so this CTE is computed N times — acceptable given the small team count (4 teams). If profiling shows this is a hot path, hoist the `device_team` CTE out of the loop and compute totals in a single grouped query.
|
||||
|
||||
#### Fix 4: `GET /vcl/stats` forecast-burndown — dedupe by `(hostname, metric_id)` in SQL
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `router.get('/vcl/stats', ...)` (around line 1015, the `forecastItems` query)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Rewrite the forecast query to `DISTINCT ON (hostname, metric_id)` so each unique violation contributes at most one `resolution_date` to the forecast bucketing:
|
||||
```sql
|
||||
SELECT DISTINCT ON (hostname, metric_id) resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
AND COALESCE(team, 'Unknown') = $1
|
||||
AND resolution_date IS NOT NULL
|
||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC
|
||||
```
|
||||
2. The downstream `blockers = teamNonCompliant - forecastItems.length` calculation is unchanged. With both sides now deduped consistently — `teamNonCompliant` from the `device_team` CTE in Fix 3, `forecastItems.length` from the deduped query above — the difference is non-negative without any clamp.
|
||||
3. The `Math.max(blockers, 0)` clamp at the existing call site is left in place as a belt-and-braces safeguard. It SHOULD be a no-op after the fix; if a regression introduces inconsistency again, the property test for Property 4 will catch it.
|
||||
|
||||
#### Fix 5: `GET /mttr` — dedupe by `(hostname, metric_id)` in SQL
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `router.get('/mttr', ...)` (around line 824)
|
||||
|
||||
**Specific Changes**:
|
||||
1. Rewrite the query to `DISTINCT ON (hostname, metric_id)` so each unique active violation contributes exactly one `(seen_count, team)` row to `bucketAgingItems()`:
|
||||
```sql
|
||||
SELECT DISTINCT ON (hostname, metric_id)
|
||||
COALESCE(seen_count, 1) AS seen_count,
|
||||
team
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC
|
||||
```
|
||||
2. `bucketAgingItems()` itself does not change. It already does the right thing when fed one row per unique violation.
|
||||
3. SQL-level dedup is preferred over a JavaScript-side `Set` here because the helper is also called from `/vcl/stats` and changing its contract risks regressions in the other caller. Pushing the dedup into SQL keeps `bucketAgingItems()` pure and reusable.
|
||||
|
||||
#### Fix 6: `persistUpload()` snapshot block — classify hostnames via `MIN(status)` in a CTE
|
||||
|
||||
**File**: `backend/routes/compliance.js`
|
||||
|
||||
**Function**: `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||
|
||||
**Specific Changes**:
|
||||
1. Rewrite the snapshot query so each hostname is classified by its worst-case status (active wins over resolved) inside a CTE before counting. Because `'active' < 'resolved'` lexicographically, `MIN(status)` returns `'active'` for a hostname that has any active row and `'resolved'` only if all rows are `'resolved'`:
|
||||
```sql
|
||||
WITH hostname_status AS (
|
||||
SELECT team,
|
||||
hostname,
|
||||
MIN(status) AS status -- 'active' beats 'resolved' alphabetically
|
||||
FROM compliance_items
|
||||
WHERE team IS NOT NULL
|
||||
GROUP BY team, hostname
|
||||
)
|
||||
SELECT team AS vertical,
|
||||
COUNT(*)::int AS total_devices,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved')::int AS compliant,
|
||||
COUNT(*) FILTER (WHERE status = 'active')::int AS non_compliant
|
||||
FROM hostname_status
|
||||
GROUP BY team
|
||||
```
|
||||
Each hostname appears exactly once in `hostname_status`, so `compliant + non_compliant == total_devices` is structurally guaranteed.
|
||||
2. The downstream `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` block is unchanged. It already keys snapshots on `vertical`, and the per-team counts now satisfy `compliant + non_compliant <= total_devices`.
|
||||
3. The `compliance_pct` calculation `Math.round((vs.compliant / total) * 100 * 100) / 100` is unchanged. It is now defended against the previous off-by-one: with the old query a hostname could be in both `compliant` and `non_compliant`, double-counting itself in `compliant`'s numerator while only contributing once to `total_devices`'s denominator. The fix removes that.
|
||||
|
||||
> Note: Fix 6 is conceptually adjacent to but mechanically distinct from the `persistUpload` fix in `compliance-duplicate-chart-entries`. That spec adds a `WHERE vertical = $1` filter to scope the snapshot to one vertical at a time. This spec adds the `hostname_status` CTE so that within whichever vertical is snapshotted, each hostname is classified once. Both fixes are independently necessary; landing them together is preferred but each is correct on its own.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Validation Approach
|
||||
|
||||
The bug condition is straightforward to construct: insert two `compliance_items` rows for the same `(hostname, metric_id)` with different `vertical` values, then call each affected endpoint. The two-phase approach is to first run the tests against the unfixed code to confirm the duplication counterexamples, then run the same tests against the fixed code and add property-based tests that explore the input space more broadly.
|
||||
|
||||
### Exploratory Bug Condition Checking
|
||||
|
||||
**Goal**: Surface counterexamples that demonstrate each of the six manifestations BEFORE implementing the fix. Confirm or refute the root cause analysis for each endpoint independently — they share a structural cause but the SQL details differ.
|
||||
|
||||
**Test Plan**: Seed a clean test database with a fixture representing the original GitLab #13 scenario (`STEAM-INTERSIGHT` failing `7.1.1` in both `vertical IS NULL` and `vertical = 'NTS_AEO'`), plus targeted variants for each sibling endpoint. Call each affected endpoint and assert the buggy invariants. Run on UNFIXED code first.
|
||||
|
||||
**Test Cases**:
|
||||
|
||||
1. **`/items` Duplicate Failing Metric Test** — Insert two rows for `(STEAM-INTERSIGHT, 7.1.1)`: one with `vertical IS NULL, status = 'active', team = 'STEAM'`, another with `vertical = 'NTS_AEO', status = 'active', team = 'STEAM'`. Call `GET /items?team=STEAM&status=active`. Assert that `response.devices[0].failing_metrics.filter(m => m.metric_id === '7.1.1').length === 1`. (will fail on unfixed code — returns 2)
|
||||
|
||||
2. **`/items/:hostname` Duplicate Metric Entry Test** — Same fixture. Call `GET /items/STEAM-INTERSIGHT`. Assert `response.metrics.filter(m => m.metric_id === '7.1.1' && m.status === 'active').length === 1`. Assert `response.metrics[0].seen_count === MAX(seen_count across the duplicate rows)`. (will fail on unfixed code — returns 2)
|
||||
|
||||
3. **`/vcl/stats` Cross-Team Hostname Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)`: one with `team = 'STEAM', vertical IS NULL`, another with `team = 'ACCESS-ENG', vertical = 'NTS_AEO'`. Call `GET /vcl/stats`. Assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant`. (will fail on unfixed code — sum exceeds the global count by 1)
|
||||
|
||||
4. **`/vcl/stats` Forecast Negative Blockers Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)` both with `team = 'STEAM', status = 'active'`, both with `resolution_date = '2025-09-30'`, but different verticals. Call `GET /vcl/stats`. Assert `vertical_breakdown.find(v => v.team === 'STEAM').blockers >= 0` AND that the unclamped `teamNonCompliant - dedupedForecastCount` equals the reported `blockers`. (will fail on unfixed code — clamped from `-1` to `0`, hiding the inconsistency; the unclamped check makes the failure visible)
|
||||
|
||||
5. **`/mttr` Inflated Bucket Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)` with `seen_count = 5, team = 'STEAM', status = 'active'`, different verticals. Compare `SUM(aging[*].total)` to `COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')`. Assert equality. (will fail on unfixed code — bucket total exceeds distinct count by 1)
|
||||
|
||||
6. **`persistUpload()` Snapshot Inconsistency Test** — Pre-populate `compliance_items` with `(SOME-DEVICE, 7.1.1)` having one `status = 'active'` row and one `status = 'resolved'` row, both `team = 'STEAM'`, different verticals. Call `persistUpload()` with a no-op upload. Read back the `compliance_snapshots` row for the current month and `STEAM`. Assert `compliant + non_compliant <= total_devices`. (will fail on unfixed code — the hostname is counted in both columns so the sum exceeds `total_devices` by 1)
|
||||
|
||||
7. **Edge Case — Single-Vertical Regression Test** — Insert a fixture where every `(hostname, metric_id)` is unique (no cross-vertical duplicates). Call all five read endpoints and capture responses. Apply the fix, re-run, and assert response equality (byte-for-byte). Run `persistUpload()` on a single-vertical fixture and assert the resulting `compliance_snapshots` rows are identical pre-fix and post-fix. (should pass on unfixed code; will pass on fixed code; protects the preservation property)
|
||||
|
||||
**Expected Counterexamples**:
|
||||
- `/items` returns `failing_metrics` arrays where `length > Set(metric_ids).size`. Cause: `groupByHostname()` pushes per row instead of per unique `metric_id`.
|
||||
- `/items/:hostname` returns `metrics` arrays where `(metric_id, status)` collisions exist. Cause: no vertical filter and no dedup in the detail query or response builder.
|
||||
- `/vcl/stats` returns `heavy_hitters` whose `non_compliant` values sum to more than the global `stats.non_compliant`. Cause: a hostname's `team` differs across verticals and the per-team `COUNT(DISTINCT hostname)` counts it under both teams.
|
||||
- `/vcl/stats` reports `blockers` clamped to 0 when the unclamped expression is negative. Cause: the forecast query is not deduped on `(hostname, metric_id)` so its row count exceeds `teamNonCompliant`.
|
||||
- `/mttr` returns `aging` whose total exceeds the distinct-violation count. Cause: `bucketAgingItems()` iterates rows directly with no dedup.
|
||||
- `persistUpload()` writes `compliance_snapshots` rows where `compliant + non_compliant > total_devices`. Cause: the snapshot query's `CASE WHEN status = 'X'` pattern lets a hostname appear in both columns.
|
||||
|
||||
### Fix Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition holds (any `(hostname, metric_id)` shared by two or more rows across verticals), each fixed endpoint produces the expected deduped result.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL items WHERE EXISTS (h, m) WITH COUNT(items WHERE hostname = h AND metric_id = m) > 1 DO
|
||||
items_response := GET_items_fixed(items)
|
||||
detail_response := GET_items_hostname_fixed(items, some_hostname_with_dups)
|
||||
stats_response := GET_vcl_stats_fixed(items)
|
||||
mttr_response := GET_mttr_fixed(items)
|
||||
snapshot_rows := persistUpload_fixed(no_op_upload, items)
|
||||
|
||||
ASSERT no_duplicate_metrics_per_device(items_response.devices)
|
||||
ASSERT no_duplicate_metric_status_pairs(detail_response.metrics)
|
||||
ASSERT sum_of_team_counts_equals_global(stats_response)
|
||||
ASSERT forecast_blockers_non_negative(stats_response.vertical_breakdown)
|
||||
ASSERT mttr_total_equals_distinct_violations(mttr_response.aging, items)
|
||||
ASSERT snapshot_invariant_holds(snapshot_rows)
|
||||
END FOR
|
||||
```
|
||||
|
||||
### Preservation Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition does NOT hold (every `(hostname, metric_id)` is unique across verticals), the fixed endpoints produce results identical to the original endpoints.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL items WHERE FORALL (h, m), COUNT(items WHERE hostname = h AND metric_id = m) <= 1 DO
|
||||
ASSERT GET_items_original(items) = GET_items_fixed(items)
|
||||
ASSERT GET_items_hostname_original(items) = GET_items_hostname_fixed(items)
|
||||
ASSERT GET_vcl_stats_original(items) = GET_vcl_stats_fixed(items)
|
||||
ASSERT GET_mttr_original(items) = GET_mttr_fixed(items)
|
||||
ASSERT persistUpload_original(items).snapshots = persistUpload_fixed(items).snapshots
|
||||
END FOR
|
||||
```
|
||||
|
||||
**Testing Approach**: Property-based testing is the right fit for preservation checking here:
|
||||
- The unique-`(hostname, metric_id)` input space is large (any number of hostnames, any combination of metrics, any team and vertical mix) and exhaustive enumeration is impractical.
|
||||
- The preservation property is strict equality, which is well-suited to PBT shrinking — any counterexample is a small fixture demonstrating a behavior change.
|
||||
- The legacy AEO-only data shape (`vertical IS NULL`) must be exercised, which falls naturally out of generators that include `null` verticals.
|
||||
|
||||
**Test Plan**: Capture responses from the unfixed code on unique-`(hostname, metric_id)` fixtures (snapshot tests). After applying the fix, re-run the same fixtures and assert equality. Then run a property-based generator that produces random unique-key scenarios and asserts the same equality.
|
||||
|
||||
**Test Cases**:
|
||||
1. **Snapshot Equality — Empty State** — Empty `compliance_items`. `/items?team=STEAM` returns `{ devices: [], team: 'STEAM', status: 'active' }`. `/items/:hostname` returns 404. `/vcl/stats` returns its zero-state shape. `/mttr` returns `{ aging: [] }`. `persistUpload()` writes no snapshot rows. Snapshot-test before and after the fix.
|
||||
2. **Snapshot Equality — Single AEO-Only Items** — Items with `vertical IS NULL` only. Capture pre-fix responses, apply fix, assert equality across all five endpoints.
|
||||
3. **Snapshot Equality — Multiple Unique Verticals** — A mix of items with `vertical IS NULL` and `vertical = 'NTS_AEO'`, but no `(hostname, metric_id)` collision across verticals. Capture pre-fix responses, apply fix, assert equality.
|
||||
4. **`/items` Team and Status Filter Preservation** — Active and resolved items mixed across teams. Assert `?team=STEAM&status=active`, `?team=STEAM&status=resolved`, and `?team=ACCESS-ENG&status=active` each return the same devices pre-fix and post-fix on a unique-key fixture. Assert non-`ALLOWED_TEAMS` value (e.g., `?team=OTHER`) returns HTTP 400.
|
||||
5. **`/items/:hostname` Active-Then-Resolved Ordering Preservation** — A device with both active and resolved metrics. Assert the response's `metrics` array has all `active` entries before all `resolved` entries, sorted by `metric_id` within each group, identical to pre-fix.
|
||||
6. **`/vcl/stats` Donut Categorization Preservation** — Active items with mixed null/non-null `resolution_date`. Assert `donut.blocked` and `donut.in_progress` counts match pre-fix on a unique-key fixture (this is already deduped via `GROUP BY hostname` in the existing query).
|
||||
7. **`persistUpload()` Snapshot Equality — Single-Status-Per-Hostname Fixture** — Pre-populate `compliance_items` so every hostname has rows of only one status (all active or all resolved within a team). Run `persistUpload()`. Assert `compliance_snapshots` rows are identical pre-fix and post-fix.
|
||||
|
||||
### Test Fixtures Needed
|
||||
|
||||
The following fixtures must be reusable across unit, integration, and property-based tests. Place them under `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/` (or inline as factory functions in the test file, matching the convention used in `vcl-compliance-reporting.test.js`).
|
||||
|
||||
1. **`fixtureCrossVerticalDuplicateActive`** — A single hostname with two rows for the same `metric_id`, both `status = 'active'`, both `team = 'STEAM'`, one with `vertical IS NULL` and one with `vertical = 'NTS_AEO'`. Different `seen_count` (e.g., 3 and 5) so the representative-row policy is exercised. Used by `/items`, `/items/:hostname`, `/mttr`, and the `/vcl/stats` forecast tests.
|
||||
|
||||
2. **`fixtureCrossVerticalTeamMismatch`** — A single hostname with two active rows for the same `metric_id` but different `team` across verticals (`STEAM` in legacy, `ACCESS-ENG` in NTS_AEO). Used by the `/vcl/stats` heavy-hitters and per-team-totals test (Property 3).
|
||||
|
||||
3. **`fixtureCrossVerticalStatusMismatch`** — A single hostname with two rows for the same `metric_id` and same team, but different `status` across verticals (`active` in legacy, `resolved` in NTS_AEO). Used by the `persistUpload()` snapshot test (Property 6).
|
||||
|
||||
4. **`fixtureMultiHostnameMixed`** — A combination of unique-key hostnames and one duplicated `(hostname, metric_id)`. Used to verify that the dedup applies only to the duplicates and leaves unique entries untouched.
|
||||
|
||||
5. **`fixtureLegacyOnly`** — All rows with `vertical IS NULL`. Used for preservation checks that confirm legacy data flows are unchanged.
|
||||
|
||||
6. **`fixtureNTSAEOOnly`** — All rows with `vertical = 'NTS_AEO'`. Used for preservation checks that confirm multi-vertical-only data flows are unchanged.
|
||||
|
||||
7. **`fixtureForecastDuplicateResolutionDate`** — A duplicated `(hostname, metric_id)` where both rows have a non-null `resolution_date` (same value). Used to verify Property 4 (`blockers >= 0`).
|
||||
|
||||
8. **`fixtureSeenCountTiebreak`** — A duplicated `(hostname, metric_id)` where both rows have the same `seen_count` but different `upload_id`. Used to verify the `upload_id DESC` tiebreaker in the `DISTINCT ON ... ORDER BY` policy.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `/items`: insert `fixtureCrossVerticalDuplicateActive`, assert `failing_metrics.length === 1` and `seen_count === 5`.
|
||||
- `/items/:hostname`: insert `fixtureCrossVerticalDuplicateActive`, assert `metrics.length === 1` for `(7.1.1, active)`.
|
||||
- `/items/:hostname` ordering: insert a fixture with one active and one resolved metric, assert `metrics[0].status === 'active'` and `metrics[1].status === 'resolved'`.
|
||||
- `/vcl/stats` heavy-hitters: insert `fixtureCrossVerticalTeamMismatch`, assert hostname counted exactly once in the team derived from the representative row.
|
||||
- `/vcl/stats` forecast: insert `fixtureForecastDuplicateResolutionDate`, assert `blockers >= 0` and `forecastItems.length === teamNonCompliant`.
|
||||
- `/mttr`: insert `fixtureCrossVerticalDuplicateActive` with `seen_count = 5`, assert exactly one increment in the `4–6 cycles` bucket for `STEAM`.
|
||||
- `persistUpload()` snapshot: insert `fixtureCrossVerticalStatusMismatch`, run `persistUpload()`, assert the resulting snapshot row has `compliant === 0`, `non_compliant === 1`, `total_devices === 1`.
|
||||
- `groupByHostname()` direct: pass a list with two rows for the same `(hostname, metric_id)`, assert one entry in `failing_metrics`. This protects the helper's contract independently of the SQL dedup.
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Use `fast-check` (already in use in `backend/__tests__/*.property.test.js`). Generators should mix `vertical IS NULL` and `vertical = 'NTS_AEO'` rows, with controllable rates of `(hostname, metric_id)` collision.
|
||||
|
||||
- **Property 1 — `/items`**: For any list of `compliance_items` rows including cross-vertical duplicates, assert that for every device in the response, `device.failing_metrics.length === new Set(device.failing_metrics.map(m => m.metric_id)).size`.
|
||||
- **Property 2 — `/items/:hostname`**: For any duplicated `(hostname, metric_id, status)`, assert `response.metrics.filter(m => m.metric_id === target_metric && m.status === target_status).length === 1`. Assert the surviving entry's `seen_count` is the maximum across duplicates.
|
||||
- **Property 3 — `/vcl/stats`**: For any input including team-mismatched cross-vertical duplicates, assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant`. Generate inputs that vary the rate of team mismatch from 0% to 100%.
|
||||
- **Property 4 — `/vcl/stats` forecast**: For any input including duplicated `(hostname, metric_id)` with non-null resolution dates, assert `vertical_breakdown[*].blockers >= 0` and assert the unclamped expression equals `teamNonCompliant - dedupedForecastCount`.
|
||||
- **Property 5 — `/mttr`**: For any input including cross-vertical duplicate active rows, assert `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` over the input. Per-team totals: assert `SUM(aging[*][team])` over each team equals the distinct count for that team.
|
||||
- **Property 6 — `persistUpload()` snapshot invariant**: For any combination of cross-vertical status mismatches, assert every row written into `compliance_snapshots` satisfies `compliant + non_compliant <= total_devices` and `compliant + non_compliant === total_devices` (equality, since every hostname is classified into exactly one column).
|
||||
- **Property 7 — Preservation**: For any input where every `(hostname, metric_id)` is unique across verticals, the responses from all five endpoints SHALL equal the responses on the same input from the original (unfixed) implementations. Use fast-check's shrinking to surface the smallest counterexample if the property fails.
|
||||
- **Property 8 — Representative-row policy**: For any duplicated `(hostname, metric_id)`, the surviving entry's `seen_count` SHALL equal `max(duplicates.seen_count)`, and ties SHALL be broken by `max(duplicates.upload_id)`.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Full flow: upload a legacy AEO xlsx, then upload an NTS_AEO multi-vertical xlsx that overlaps on at least one `(hostname, metric_id)`. Call `/items`, `/items/:hostname`, `/vcl/stats`, `/mttr`, and read `compliance_snapshots` for the current month. Assert all six post-fix invariants hold.
|
||||
- Cross-page consistency: load `/vcl/stats` and `/items?team=STEAM` for the same backing data. Assert that the device count from `/vcl/stats` heavy-hitters for `STEAM` equals the number of devices in `/items?team=STEAM&status=active`.
|
||||
- ComplianceDetailPanel render: load a device with cross-vertical duplicates via `/items/:hostname`, render `ComplianceDetailPanel`, assert exactly one `MetricChip` with the duplicated `metric_id` is present in the "Failing Metrics" section.
|
||||
- `persistUpload()` end-to-end: with cross-vertical status mismatch present, run a fresh upload and assert the new `compliance_snapshots` row's invariant holds.
|
||||
204
.kiro/specs/compliance-duplicate-failing-metrics/tasks.md
Normal file
204
.kiro/specs/compliance-duplicate-failing-metrics/tasks.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation plan delivers six SQL-level fixes in `backend/routes/compliance.js` for the cross-vertical duplicate `(hostname, metric_id)` bug class documented in `bugfix.md` and `design.md`. Each fix targets a single endpoint or persistence block:
|
||||
|
||||
1. `GET /items` — `DISTINCT ON (hostname, metric_id)` plus a defensive `groupByHostname` Set
|
||||
2. `GET /items/:hostname` — `DISTINCT ON (metric_id, status)` with JS-side sort
|
||||
3. `GET /vcl/stats` heavy-hitters and per-team totals — `device_team` CTE
|
||||
4. `GET /vcl/stats` forecast-burndown — `DISTINCT ON (hostname, metric_id)`
|
||||
5. `GET /mttr` — `DISTINCT ON (hostname, metric_id)`
|
||||
6. `persistUpload()` snapshot block — `hostname_status` CTE classifying each hostname via `MIN(status)`
|
||||
|
||||
The plan follows the bugfix workflow: a single property-based exploration test (Property 1) covering all six affected sites runs on UNFIXED code first to confirm the bug exists, then preservation property tests (Property 2) capture single-vertical and unique-key behaviour as a baseline. Implementation tasks apply each fix in order (3.1–3.6), followed by re-running the exploration test (3.7) and the preservation suite (3.8). The checkpoint runs the full backend test suite to confirm no regressions.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
Tasks are organised into execution waves. Tasks within the same wave may run in parallel; tasks in later waves depend on the completion of earlier waves. Sub-tasks 3.1–3.6 are independent of each other (each touches a distinct query in `backend/routes/compliance.js`) and form a single parallel wave; sub-tasks 3.7 and 3.8 depend on all six fixes being landed.
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{
|
||||
"wave": 1,
|
||||
"description": "Write the bug-condition exploration property test on UNFIXED code (Property 1, six slices covering /items, /items/:hostname, /vcl/stats heavy-hitters, /vcl/stats forecast-burndown, /mttr, persistUpload snapshot).",
|
||||
"tasks": ["1"]
|
||||
},
|
||||
{
|
||||
"wave": 2,
|
||||
"description": "Write the preservation property tests on UNFIXED code (observation-first methodology), recording baseline responses for single-vertical and unique-key fixtures across all five read endpoints and persistUpload(). Property 8.A is written but skipped pending the fix.",
|
||||
"tasks": ["2"]
|
||||
},
|
||||
{
|
||||
"wave": 3,
|
||||
"description": "Apply the six SQL-level fixes in backend/routes/compliance.js. Each sub-task targets a distinct query and is independent of the others — sub-tasks 3.1 through 3.6 can be implemented in parallel.",
|
||||
"tasks": ["3.1", "3.2", "3.3", "3.4", "3.5", "3.6"]
|
||||
},
|
||||
{
|
||||
"wave": 4,
|
||||
"description": "Re-run the exploration test (3.7) and preservation tests (3.8) against the fixed code. 3.7 confirms all six bug-condition slices now pass; 3.8 confirms preservation properties still pass and unskips Property 8.A to verify the representative-row policy.",
|
||||
"tasks": ["3.7", "3.8"]
|
||||
},
|
||||
{
|
||||
"wave": 5,
|
||||
"description": "Checkpoint — run the full backend Jest suite to confirm no regressions in adjacent compliance tests (vcl-compliance-reporting, vcl-aggregated-burndown).",
|
||||
"tasks": ["4"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Write bug condition exploration property test
|
||||
- **Property 1: Bug Condition** - Cross-Vertical Duplicate `(hostname, metric_id)` Distorts Compliance Endpoints
|
||||
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists in all six affected sites
|
||||
- **DO NOT attempt to fix the test or the code when it fails**
|
||||
- **NOTE**: This test encodes the expected behaviour (Property 1–6 from design.md). It will validate the fix when it passes after implementation.
|
||||
- **GOAL**: Surface counterexamples that demonstrate cross-vertical duplicate `(hostname, metric_id)` rows distort `/items`, `/items/:hostname`, `/vcl/stats` heavy-hitters, `/vcl/stats` forecast-burndown, `/mttr`, and the `persistUpload()` snapshot block
|
||||
- **Scoped PBT Approach**: Six concrete failing scenarios (one per affected site) generated by fast-check from a small fixed input space — each scenario seeds two `compliance_items` rows for the same `(hostname, metric_id)` across `vertical IS NULL` and `vertical = 'NTS_AEO'`. Use `fast-check` (already in use under `backend/__tests__/*.property.test.js`).
|
||||
- Place the test at `backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js`. Mock `../db` with `jest.mock` exactly like `vcl-compliance-reporting.property.test.js` so route handlers can be invoked against an in-memory fixture, or stand up a transactional `pg` test schema if the repo already supports one — match whichever convention the existing compliance tests use.
|
||||
- **Slice 1.A — `/items` failing-metrics dedup (Bug Condition isBugCondition: two active rows for `(STEAM-INTERSIGHT, 7.1.1)`, one `vertical IS NULL`, one `vertical = 'NTS_AEO'`, both `team = 'STEAM'`)**
|
||||
- Seed `fixtureCrossVerticalDuplicateActive` from design.md §Test Fixtures (different `seen_count`: 3 and 5)
|
||||
- Call `GET /items?team=STEAM&status=active`
|
||||
- Assert `response.devices[0].failing_metrics.filter(m => m.metric_id === '7.1.1').length === 1` (Property 1 from design.md)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — `failing_metrics` contains two `7.1.1` entries because `groupByHostname` pushes per row
|
||||
- **Slice 1.B — `/items/:hostname` `(metric_id, status)` dedup (same fixture)**
|
||||
- Call `GET /items/STEAM-INTERSIGHT`
|
||||
- Assert `response.metrics.filter(m => m.metric_id === '7.1.1' && m.status === 'active').length === 1` (Property 2 from design.md)
|
||||
- Assert the surviving entry's `seen_count === 5` (max across duplicates per Property 8)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — detail query has no vertical filter and no dedup, returns two `7.1.1/active` entries
|
||||
- **Slice 1.C — `/vcl/stats` heavy-hitters cross-team (Bug Condition: two active rows for the same `(hostname, metric_id)` whose `team` differs across verticals)**
|
||||
- Seed `fixtureCrossVerticalTeamMismatch` from design.md §Test Fixtures (`team = 'STEAM'` legacy, `team = 'ACCESS-ENG'` NTS_AEO)
|
||||
- Call `GET /vcl/stats`
|
||||
- Assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant` (Property 3 from design.md)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — sum exceeds the global count by 1 because `COUNT(DISTINCT hostname) GROUP BY team` counts the hostname under both teams
|
||||
- **Slice 1.D — `/vcl/stats` forecast-burndown blockers (Bug Condition: two active rows for the same `(hostname, metric_id)` both with non-null `resolution_date`, same team)**
|
||||
- Seed `fixtureForecastDuplicateResolutionDate` from design.md §Test Fixtures (`team = 'STEAM'`, both `resolution_date = '2025-09-30'`)
|
||||
- Call `GET /vcl/stats`, locate the `STEAM` entry in `vertical_breakdown`
|
||||
- Assert the unclamped `teamNonCompliant - forecastItems.length === blockers` AND `blockers >= 0` (Property 4 from design.md)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — unclamped value is `-1`, route reports `0` after `Math.max(blockers, 0)`, hiding the inconsistency. The unclamped check makes the failure visible
|
||||
- **Slice 1.E — `/mttr` aging buckets (same fixture as Slice 1.A, with `seen_count = 5` on both rows)**
|
||||
- Call `GET /mttr`
|
||||
- Assert `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` over the seeded items (Property 5 from design.md)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — `bucketAgingItems()` increments the `4–6 cycles` bucket twice, exceeding the distinct count by 1
|
||||
- **Slice 1.F — `persistUpload()` snapshot block (Bug Condition: same `(hostname, metric_id, team)` with `status = 'active'` in legacy and `status = 'resolved'` in NTS_AEO)**
|
||||
- Seed `fixtureCrossVerticalStatusMismatch` from design.md §Test Fixtures
|
||||
- Run `persistUpload()` with a no-op upload, read back the `compliance_snapshots` row for the current month and `STEAM`
|
||||
- Assert `compliant + non_compliant <= total_devices` (Property 6 from design.md)
|
||||
- **EXPECTED OUTCOME on unfixed code**: FAILS — the hostname is counted in both `compliant` and `non_compliant`, sum exceeds `total_devices` by 1
|
||||
- Run on UNFIXED code and capture all six counterexamples in the test output
|
||||
- Document the six counterexamples in the test file (a leading comment block listing the failing slice → symptom mapping) so the bug surface is recoverable from the test alone
|
||||
- Mark task complete when the test is written, run, and all six slice failures are documented
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||
|
||||
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||
- **Property 2: Preservation** - Single-Vertical and Unique-Key Inputs Are Byte-For-Byte Unchanged
|
||||
- **IMPORTANT**: Follow observation-first methodology — observe behaviour on UNFIXED code, then write property tests that capture it
|
||||
- Place the test at `backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js`. Use `fast-check` and the same `jest.mock('../db', ...)` pattern as the existing property tests
|
||||
- Build the unique-key generator: `fc.array(...)` of `compliance_items` rows where every `(hostname, metric_id)` pair is unique across the array. Mix `vertical IS NULL`, `vertical = 'NTS_AEO'`, and other verticals; mix `status = 'active'` and `status = 'resolved'`; mix teams from `ALLOWED_TEAMS`
|
||||
- **Observation step**: For each fixture from design.md §Test Fixtures (`fixtureLegacyOnly`, `fixtureNTSAEOOnly`, `fixtureMultiHostnameMixed` restricted to its unique-key subset, plus a hand-built empty-state fixture), run all five read endpoints + `persistUpload()` on UNFIXED code and capture responses. Persist these as snapshot fixtures alongside the test (e.g., a small JSON file under `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/`) so the test compares against the recorded baseline rather than against a moving target
|
||||
- **Property 7.A — `/items` unique-key preservation**: For any unique-key fixture, assert the response from `GET /items?team=...&status=...` (across `team ∈ ALLOWED_TEAMS` and `status ∈ {'active', 'resolved'}`) equals the recorded baseline byte-for-byte (deep equality on the JSON response)
|
||||
- **Property 7.B — `/items/:hostname` unique-key preservation**: For any unique-key fixture, assert the response from `GET /items/:hostname` for every seeded hostname equals the recorded baseline. Also assert active-then-resolved ordering is preserved (`response.metrics[0..k].status === 'active'` then `response.metrics[k+1..].status === 'resolved'`, sorted by `metric_id` within each group), per design.md §Preservation Requirements item 5
|
||||
- **Property 7.C — `/vcl/stats` unique-key preservation**: Assert response equality across `stats.compliant`, `stats.non_compliant`, the `donut` block (`blocked` / `in_progress`), `heavy_hitters` (full array), and `vertical_breakdown` (full array including `blockers`). Generate inputs that vary `resolution_date` density to exercise the donut categorisation
|
||||
- **Property 7.D — `/mttr` unique-key preservation**: Assert response equality on the full `aging` array (per-bucket per-team totals)
|
||||
- **Property 7.E — `persistUpload()` unique-key preservation**: For a single-status-per-hostname fixture (every hostname has only `active` or only `resolved` rows, never both, within a team), run `persistUpload()` and assert the `compliance_snapshots` rows for the current `(snapshot_month, vertical)` are identical to the recorded baseline
|
||||
- **Property 7.F — `/items` query-param validation preservation**: Assert that `?team=OTHER` (not in `ALLOWED_TEAMS`) returns HTTP 400 and `?status=invalid` returns HTTP 400, on both unfixed and fixed code. Assert `/items/:hostname` for an unknown hostname returns HTTP 404
|
||||
- **Property 8.A — Representative-row policy on duplicates**: For inputs WITH duplicates, assert the surviving entry carries `seen_count = MAX(seen_count)`, `first_seen = MIN(first_seen)`, `last_seen = MAX(last_seen)` across the duplicate rows (this is the only preservation property that exercises the duplicate path, since it specifies WHAT the dedup must produce — keep it in the preservation file because it defines the contract the fix must satisfy)
|
||||
- Run the full preservation suite on UNFIXED code
|
||||
- **EXPECTED OUTCOME**: Properties 7.A–7.F PASS on unfixed code (they describe baseline behaviour to preserve). Property 8.A is the only one expected to FAIL on unfixed code, since it asserts the post-fix representative-row contract — exclude it from the unfixed-code run or mark it as `test.skip` until the fix lands, with a comment pointing to task 3.5
|
||||
- Mark task complete when 7.A–7.F are written, run, and passing on unfixed code, and 8.A is written but skipped pending the fix
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7_
|
||||
|
||||
- [x] 3. Fix for cross-vertical duplicate `(hostname, metric_id)` rows distorting compliance endpoints
|
||||
|
||||
- [x] 3.1 Implement Fix 1: `GET /items` `DISTINCT ON (hostname, metric_id)` and defensive `groupByHostname` dedup
|
||||
- Edit `backend/routes/compliance.js`, `router.get('/items', ...)` (around line 535)
|
||||
- Rewrite the items query as `SELECT DISTINCT ON (ci.hostname, ci.metric_id) ... ORDER BY ci.hostname, ci.metric_id, ci.seen_count DESC, ci.upload_id DESC` keeping the existing `WHERE ci.team = $1 AND ci.status = $2 AND (ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate intact (per design.md Fix 1 step 1 and Preservation Requirements bullet 4)
|
||||
- Edit `groupByHostname()` (around line 213) to add a `_seenMetricIds` Set per device, push `failing_metrics` only when the `metric_id` has not been seen, aggregate `seen_count` via `Math.max`, `first_seen` via `Math.min`, `last_seen` via `Math.max`, and strip `_seenMetricIds` from the returned device object so the response shape is unchanged (per design.md Fix 1 step 2)
|
||||
- _Bug_Condition: isBugCondition(items) — two or more rows share `(hostname, metric_id)` across verticals (design.md §Bug Condition)_
|
||||
- _Expected_Behavior: Property 1 — `device.failing_metrics.length === new Set(device.failing_metrics.map(m => m.metric_id)).size` (design.md §Correctness Properties Property 1)_
|
||||
- _Preservation: Single-vertical and unique-key inputs unchanged; `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate retained; `team`/`status` query-param validation unchanged (design.md §Preservation Requirements bullets 1, 4, 7)_
|
||||
- _Requirements: 2.1, 3.1, 3.3, 3.4_
|
||||
|
||||
- [x] 3.2 Implement Fix 2: `GET /items/:hostname` `DISTINCT ON (metric_id, status)` and JS sort
|
||||
- Edit `backend/routes/compliance.js`, `router.get('/items/:hostname', ...)` (around line 575)
|
||||
- Rewrite the metrics query as `SELECT DISTINCT ON (ci.metric_id, ci.status) ... FROM compliance_items ci ... WHERE ci.hostname = $1 ORDER BY ci.metric_id, ci.status, ci.seen_count DESC, ci.upload_id DESC` (per design.md Fix 2 step 1)
|
||||
- Add a JS-side sort on the deduped rows that reproduces the original `ORDER BY ci.status DESC, ci.metric_id` ordering — `metricRows.sort((a, b) => a.status !== b.status ? b.status.localeCompare(a.status) : a.metric_id.localeCompare(b.metric_id))` (per design.md Fix 2 step 2)
|
||||
- Leave the existing `metricRows.find(r => r.status === 'active') || metricRows[0]` identity lookup unchanged (per design.md Fix 2 step 3)
|
||||
- _Bug_Condition: isBugCondition(items) plus duplicate rows for the same `hostname` across verticals with no vertical filter on the detail query_
|
||||
- _Expected_Behavior: Property 2 — exactly one entry per `(metric_id, status)` pair, surviving entry carries `MAX(seen_count)` across duplicates_
|
||||
- _Preservation: active-before-resolved ordering, `metric_id` ordering within each status group, identity lookup unchanged_
|
||||
- _Requirements: 2.2, 2.3, 3.2, 3.3_
|
||||
|
||||
- [x] 3.3 Implement Fix 3: `/vcl/stats` heavy-hitters and per-team totals via `device_team` CTE
|
||||
- Edit `backend/routes/compliance.js`, `router.get('/vcl/stats', ...)` (around line 990, the `teamRows` query)
|
||||
- Replace the heavy-hitters query with a CTE: `WITH device_team AS (SELECT DISTINCT ON (hostname) hostname, COALESCE(team, 'Unknown') AS team, resolution_date FROM compliance_items WHERE status = 'active' ORDER BY hostname, seen_count DESC, upload_id DESC) SELECT team, COUNT(DISTINCT hostname)::int AS non_compliant, MAX(resolution_date) AS compliance_date FROM device_team GROUP BY team ORDER BY COUNT(DISTINCT hostname) DESC` (per design.md Fix 3 step 1)
|
||||
- Rewrite the per-team-total query inside the `for (const teamRow of teamRows)` loop to use the same `device_team`-style CTE: `WITH device_team AS (SELECT DISTINCT ON (hostname) hostname, COALESCE(team, 'Unknown') AS team FROM compliance_items ORDER BY hostname, seen_count DESC, upload_id DESC) SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1` (per design.md Fix 3 step 3)
|
||||
- Leave the global `stats.non_compliant` query unchanged — it is already correct (per design.md Fix 3 step 2)
|
||||
- _Bug_Condition: a hostname's `team` differs across verticals so `COUNT(DISTINCT hostname) GROUP BY team` counts it under both teams_
|
||||
- _Expected_Behavior: Property 3 — `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant` and each hostname assigned to exactly one team (the team from its representative row)_
|
||||
- _Preservation: global `stats.compliant` / `stats.non_compliant` unchanged, donut categorisation unchanged_
|
||||
- _Requirements: 2.5, 3.6_
|
||||
|
||||
- [x] 3.4 Implement Fix 4: `/vcl/stats` forecast-burndown `DISTINCT ON (hostname, metric_id)`
|
||||
- Edit `backend/routes/compliance.js`, `router.get('/vcl/stats', ...)` (around line 1015, the `forecastItems` query)
|
||||
- Rewrite as `SELECT DISTINCT ON (hostname, metric_id) resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC` (per design.md Fix 4 step 1)
|
||||
- Leave `blockers = teamNonCompliant - forecastItems.length` and the `Math.max(blockers, 0)` clamp unchanged — the clamp becomes a no-op in correct operation but stays as belt-and-braces (per design.md Fix 4 steps 2 and 3)
|
||||
- _Bug_Condition: duplicate `(hostname, metric_id)` rows with non-null `resolution_date` inflate `forecastItems.length` past `teamNonCompliant`_
|
||||
- _Expected_Behavior: Property 4 — unclamped `teamNonCompliant - dedupedForecastCount === blockers` and `blockers >= 0`_
|
||||
- _Preservation: `Math.max(blockers, 0)` clamp retained as a no-op safeguard, downstream forecast bucketing unchanged_
|
||||
- _Requirements: 2.6, 3.6_
|
||||
|
||||
- [x] 3.5 Implement Fix 5: `/mttr` `DISTINCT ON (hostname, metric_id)` in SQL
|
||||
- Edit `backend/routes/compliance.js`, `router.get('/mttr', ...)` (around line 824)
|
||||
- Rewrite the query as `SELECT DISTINCT ON (hostname, metric_id) COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active' ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC` (per design.md Fix 5 step 1)
|
||||
- Leave `bucketAgingItems()` unchanged — its contract is preserved because it is also called from `/vcl/stats` (per design.md Fix 5 steps 2 and 3)
|
||||
- _Bug_Condition: duplicate active rows for the same `(hostname, metric_id)` are bucketed twice by `bucketAgingItems()`_
|
||||
- _Expected_Behavior: Property 5 — `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` and each unique violation bucketed exactly once with a single representative `seen_count`_
|
||||
- _Preservation: `bucketAgingItems()` contract unchanged, per-team buckets on single-vertical fixtures unchanged_
|
||||
- _Requirements: 2.7, 3.7_
|
||||
|
||||
- [x] 3.6 Implement Fix 6: `persistUpload()` snapshot via `hostname_status` CTE
|
||||
- Edit `backend/routes/compliance.js`, `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||
- Rewrite the snapshot query as `WITH hostname_status AS (SELECT team, hostname, MIN(status) AS status FROM compliance_items WHERE team IS NOT NULL GROUP BY team, hostname) SELECT team AS vertical, COUNT(*)::int AS total_devices, COUNT(*) FILTER (WHERE status = 'resolved')::int AS compliant, COUNT(*) FILTER (WHERE status = 'active')::int AS non_compliant FROM hostname_status GROUP BY team` (per design.md Fix 6 step 1)
|
||||
- Leave the downstream `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` block and `compliance_pct` calculation unchanged (per design.md Fix 6 steps 2 and 3)
|
||||
- _Bug_Condition: a hostname has both `active` and `resolved` rows for the same team across verticals, so the `CASE WHEN status = 'X' THEN hostname END` pattern lets it appear in both `compliant` and `non_compliant`_
|
||||
- _Expected_Behavior: Property 6 — every snapshot row satisfies `compliant + non_compliant === total_devices` (active wins over resolved via `MIN(status)`)_
|
||||
- _Preservation: snapshot rows for single-status-per-hostname fixtures unchanged, error-handling try/catch unchanged_
|
||||
- _Requirements: 2.4, 3.5_
|
||||
|
||||
- [x] 3.7 Verify bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - Cross-Vertical Duplicate `(hostname, metric_id)` Distorts Compliance Endpoints
|
||||
- **IMPORTANT**: Re-run the SAME test from task 1 — do NOT write a new test
|
||||
- The test from task 1 encodes the expected behaviour for all six slices (1.A–1.F). When all six slices pass, the bug is fixed across every affected site
|
||||
- Run `npx jest backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js --runInBand` (or the repo's equivalent jest invocation)
|
||||
- **EXPECTED OUTCOME**: All six slices PASS — Slice 1.A (`/items` dedup), Slice 1.B (`/items/:hostname` dedup), Slice 1.C (`/vcl/stats` heavy-hitters), Slice 1.D (`/vcl/stats` forecast blockers), Slice 1.E (`/mttr` aging), Slice 1.F (`persistUpload()` snapshot invariant)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||
|
||||
- [x] 3.8 Verify preservation tests still pass and unskip Property 8.A
|
||||
- **Property 2: Preservation** - Single-Vertical and Unique-Key Inputs Are Byte-For-Byte Unchanged
|
||||
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||
- Unskip Property 8.A (the representative-row policy assertion) which was deferred in task 2 because it asserts the post-fix contract
|
||||
- Run `npx jest backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js --runInBand`
|
||||
- **EXPECTED OUTCOME**: Properties 7.A–7.F continue to PASS (no regressions on unique-key or single-vertical inputs) and Property 8.A now PASSES on the duplicate path (`seen_count = MAX`, `first_seen = MIN`, `last_seen = MAX` across duplicates)
|
||||
- Confirm the recorded baseline JSON snapshots match the post-fix output for every unique-key fixture
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7_
|
||||
|
||||
- [x] 4. Checkpoint - Run full backend test suite and confirm all tests pass
|
||||
- Run the full backend test suite from `backend/`: `npx jest --runInBand` (or the repo's standard test command)
|
||||
- Confirm both new property test files pass and that no existing tests under `backend/__tests__/` regressed — particularly `vcl-compliance-reporting.property.test.js`, `vcl-aggregated-burndown.property.test.js`, and `vcl-compliance-reporting.test.js`, all of which exercise overlapping compliance code paths
|
||||
- If any pre-existing test fails, diagnose whether the failure is a genuine regression introduced by the fix or a pre-existing flake. If a regression, return to the relevant sub-task in step 3 and adjust the fix; do not silence the failing test
|
||||
- Ask the user if any unexpected questions arise about test scope, fixture naming, or whether any preservation snapshot needs to be re-recorded against the fixed code
|
||||
- Mark complete when the full suite is green
|
||||
|
||||
## Notes
|
||||
|
||||
- **Fix sequencing**: Tasks 3.1 through 3.6 are independent — each one targets a distinct query and can be implemented and committed in any order. Implementers may parallelise work across these sub-tasks, but tasks 3.7 and 3.8 depend on all six fixes being landed before they can validate.
|
||||
- **Test framework**: Both new property tests follow the existing `backend/__tests__/*.property.test.js` convention — `fast-check` for generators, `jest.mock('../db', ...)` for mocking the `pg` pool, and matching helper imports (`auditLog`, `ivantiApi`) where required. See `vcl-compliance-reporting.property.test.js` for the canonical pattern.
|
||||
- **Fixture location**: Place fixtures at `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/` if a directory-based layout is preferred, or inline as factory functions in the test files. Match whichever convention the existing compliance test files use — if they inline factories (as `vcl-compliance-reporting.test.js` does), follow suit.
|
||||
- **Property 8.A skip**: Property 8.A in task 2 is intentionally skipped on unfixed code because it asserts the post-fix representative-row contract. Task 3.8 unskips it. This is the only test that exercises the duplicate path inside the preservation file; it lives there because the contract it captures is precisely the one preservation must not violate after the fix lands.
|
||||
- **Adjacent spec coordination**: Fix 6 (`persistUpload()` snapshot) is conceptually adjacent to but mechanically distinct from the `persistUpload` fix in `compliance-duplicate-chart-entries`. That spec adds a `WHERE vertical = $1` filter to scope the snapshot to one vertical; this spec adds the `hostname_status` CTE so each hostname is classified once within whichever vertical is snapshotted. Both are independently necessary. If both specs land in the same release, ensure the merged query carries both the vertical filter AND the `hostname_status` CTE.
|
||||
- **`Math.max(blockers, 0)` clamp**: Left in place as a belt-and-braces safeguard per design.md Fix 4 step 3. After the fix it becomes a no-op; if a future regression reintroduces inconsistency, Property 4 (Slice 1.D) catches it before the clamp can mask the underlying bug.
|
||||
- **Documentation follow-up**: Per `.kiro/steering/workflow.md`, after the fix lands and is committed to `master`, add a bug report under `docs/bug-reports/` on the `ops/records` branch using the `compliance-duplicate-failing-metrics-<YYYY-MM-DD>.md` naming convention. Each of the six fix sites is a separate `## Bug N` entry following the Symptom → Cause → Fix triad.
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "7b867406-4d3d-4728-9b3c-94ea7c6f51ef", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Bugfix Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
GitLab issue [#23](http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23) — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar" (reported by nkapur on the AEO Compliance page).
|
||||
|
||||
After a user sets a resolution date and/or remediation plan in the host detail sidebar (`ComplianceDetailPanel`) and closes it, the new value does not appear in the host list on `CompliancePage` until the list is refreshed by some other action (changing filters, switching teams, switching tabs, completing an upload, adding a note, or clicking the manual refresh button).
|
||||
|
||||
This is purely a frontend state-refresh issue. The predecessor spec `compliance-remediation-display-fix` already fixed the list endpoint (`GET /api/compliance/items?team=X&status=Y`) to include `resolution_date` and `remediation_plan` in the SELECT and propagate them through `groupByHostname()`, so the backend list endpoint already returns the correct data. The defect is that `CompliancePage` never re-fetches the device list after a sidebar metadata save, so the in-memory `devices` state (populated by `fetchDevices`) keeps the stale value until one of the existing refresh triggers fires. The backend is out of scope for this fix.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- `frontend/src/components/pages/CompliancePage.js` renders `<ComplianceDetailPanel>` (around lines 696–702) with `onClose={() => setSelectedHost(null)}` (clears selection, no list refresh) and `onNoteAdded={refresh}` (where `refresh` runs `fetchSummary` + `fetchDevices`, but `onNoteAdded` is only invoked when a note is added or deleted).
|
||||
- `frontend/src/components/pages/ComplianceDetailPanel.js` has `handleSaveMetadata()` which `PATCH`es `/api/compliance/items/:hostname/metadata` then calls its own `fetchDetail()` to refresh the panel, but never invokes any parent callback after a metadata save. As a result the parent list held in `CompliancePage` state is never re-fetched after a resolution date / remediation plan edit.
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Current Behavior (Defect)
|
||||
|
||||
1.1 WHEN the user saves a resolution date and/or remediation plan in the sidebar via `handleSaveMetadata()` (`PATCH /api/compliance/items/:hostname/metadata` succeeds) THEN the system refreshes only the panel's own detail (`fetchDetail()`) and does not notify the parent `CompliancePage`, so `fetchDevices` is never re-issued.
|
||||
|
||||
1.2 WHEN the user closes the sidebar after a successful metadata save via `onClose` THEN the system only clears `selectedHost` (`setSelectedHost(null)`) and does not trigger any list refresh, so the parent `devices` state retains the pre-edit value.
|
||||
|
||||
1.3 WHEN the host list row re-renders after a sidebar metadata save without an intervening refresh THEN the system displays the stale Resolution Date / Remediation Plan value (or "—") because `devices` in `CompliancePage` state still holds the value from the last `fetchDevices` call.
|
||||
|
||||
### Expected Behavior (Correct)
|
||||
|
||||
2.1 WHEN the user saves a resolution date and/or remediation plan in the sidebar via `handleSaveMetadata()` and the `PATCH` succeeds THEN the system SHALL invoke a parent refresh callback (mirroring the existing `onNoteAdded` pattern) that re-issues `fetchDevices` so the parent list reflects the updated values.
|
||||
|
||||
2.2 WHEN the parent list has been re-fetched following a successful sidebar metadata save THEN the system SHALL display the updated Resolution Date / Remediation Plan value in the corresponding host list row without requiring a manual filter change, team change, tab change, or manual refresh.
|
||||
|
||||
### Unchanged Behavior (Regression Prevention)
|
||||
|
||||
3.1 WHEN a user adds or deletes a note in the sidebar THEN the system SHALL CONTINUE TO invoke the existing `onNoteAdded` refresh callback exactly as before.
|
||||
|
||||
3.2 WHEN a user changes team, changes the active/resolved tab, completes an upload, or clicks the manual refresh button THEN the system SHALL CONTINUE TO refresh the list via the existing `fetchSummary` + `fetchDevices` triggers.
|
||||
|
||||
3.3 WHEN a metadata save fails (the `PATCH` returns a non-OK response and `handleSaveMetadata()` sets `metaError`) THEN the system SHALL CONTINUE TO surface the error in the panel and SHALL NOT require the list to display changed values for a save that did not persist.
|
||||
|
||||
3.4 WHEN the user closes the sidebar without having saved any metadata change THEN the system SHALL CONTINUE TO clear the selection as before, and no unnecessary behavioral regression SHALL be introduced for the no-change case.
|
||||
|
||||
3.5 WHEN the host list renders hostname, IP address, device type, failing metrics, and seen count THEN the system SHALL CONTINUE TO display these fields correctly with no change in behavior.
|
||||
|
||||
3.6 WHEN the sidebar saves metadata THEN the system SHALL CONTINUE TO call its own `fetchDetail()` so the panel's in-place view and history remain correct.
|
||||
116
.kiro/specs/compliance-list-stale-after-sidebar-edit/tasks.md
Normal file
116
.kiro/specs/compliance-list-stale-after-sidebar-edit/tasks.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Implementation Plan: Compliance List Stale After Sidebar Edit
|
||||
|
||||
## Overview
|
||||
|
||||
Fix the frontend state-refresh defect described in GitLab issue [#23](http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23). After a successful metadata save in `ComplianceDetailPanel` (`PATCH /api/compliance/items/:hostname/metadata`), the host list on `CompliancePage` keeps showing the pre-edit Resolution Date / Remediation Plan until an unrelated refresh trigger fires. The backend list endpoint already returns the correct data (fixed in the predecessor spec `compliance-remediation-display-fix`); the defect is purely that the sidebar never notifies the parent after a metadata save, so `fetchDevices` is never re-issued.
|
||||
|
||||
The fix mirrors the existing `onNoteAdded` → `refresh` pattern: `handleSaveMetadata()` invokes a new parent callback (`onMetadataSaved`) after a successful `PATCH`, and `CompliancePage` wires that callback to `refresh` (`fetchSummary` + `fetchDevices`). The backend is out of scope.
|
||||
|
||||
All tasks below are coding/test tasks. Tests live in the frontend and run via `react-scripts test` (`CI=true npm test`), using `@testing-library/react`, `@testing-library/jest-dom`, and `fast-check` (already permitted by `jest.transformIgnorePatterns` in `frontend/package.json`).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Write bug condition exploration property test
|
||||
- **Property 1: Bug Condition** - List Row Stays Stale After Sidebar Metadata Save
|
||||
- **CRITICAL**: This test MUST FAIL on the current unfixed code — failure confirms the bug exists
|
||||
- **DO NOT attempt to fix the test or the code when it fails** — the failure is the expected, desired outcome at this step
|
||||
- **NOTE**: This test encodes the expected behavior (Property 1 below) — it will validate the fix when it passes after implementation
|
||||
- **GOAL**: Surface counterexamples demonstrating that after a successful sidebar metadata save the parent `CompliancePage` list does NOT reflect the saved value without a manual refresh
|
||||
- **Test file**: `frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.exploration.property.test.js`
|
||||
- **Header comment**: reference spec `.kiro/specs/compliance-list-stale-after-sidebar-edit/` and issue #23, and state "EXPECTED TO FAIL on unfixed code — failure confirms the bug" (mirrors `backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js` tagging convention)
|
||||
- **Bug Condition** (from bugfix.md Current Behavior): `isBugCondition(input) = a successful PATCH /api/compliance/items/:hostname/metadata occurred from the sidebar` — under this condition the parent list is never re-fetched (1.1), `onClose` only clears `selectedHost` (1.2), and the row shows the stale value (1.3)
|
||||
- **Scoped PBT Approach**: use `fast-check` to generate varied saved-metadata values across runs and assert the universal property holds for each:
|
||||
- `fc.assert(fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (resolutionDate, remediationPlan) => { ... }), { numRuns: ... })`
|
||||
- `arbResolutionDate`: build `YYYY-MM-DD` strings from integer tuples (do NOT call `toISOString` on shrunk values — follow the date-generator pattern in the predecessor exploration test)
|
||||
- `arbRemediationPlan`: non-empty trimmed strings, length-bounded (e.g. 1–200)
|
||||
- **Property under test**: _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 manual refresh click
|
||||
- **Test structure**:
|
||||
- Mock `useAuth` from `../../contexts/AuthContext` (e.g. `jest.mock`) to return a stub: `canWrite: () => true`, `isAdmin: () => false`, `getAvailableTeams: () => ['STEAM']`, `adminScope: null`
|
||||
- Mock `global.fetch` with a URL-routing implementation responding to:
|
||||
- `GET /compliance/summary?team=STEAM` → minimal summary `{ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } }`
|
||||
- `GET /compliance/items?team=STEAM&status=active` → `{ devices: [oneDevice] }` where the device starts with a stale value (e.g. `resolution_date: null`, `remediation_plan: null`) on the FIRST call, and returns the generated saved value on SUBSEQUENT calls (so a re-fetch is observable)
|
||||
- `GET /compliance/items/:hostname` → detail object with one active metric so the panel renders the Resolution Date / Remediation Plan inputs and Save button
|
||||
- `PATCH /compliance/items/:hostname/metadata` → `{ ok: true }` success response
|
||||
- Render `<CompliancePage onNavigate={() => {}} />`, wait for the device row to appear, click the row to open `ComplianceDetailPanel`
|
||||
- Set the Resolution Date input and/or Remediation Plan textarea to the generated value(s) and click **Save** (use `@testing-library/user-event` or `fireEvent`, wrapped in `act`/`waitFor`)
|
||||
- **Primary assertion (encodes Property 1)**: `await waitFor(() => expect(within(row).getByText(<generated value, sliced to 10 for the date>)).toBeInTheDocument())` — the list row reflects the saved value
|
||||
- **Supporting assertion**: the list endpoint `GET /compliance/items?...` is called more than once (initial load + post-save refresh); on unfixed code it is called exactly once
|
||||
- Run on UNFIXED code: `cd frontend && CI=true npm test -- --testPathPattern compliance-list-stale-after-sidebar-edit.exploration`
|
||||
- **EXPECTED OUTCOME**: Test FAILS — `handleSaveMetadata()` never invokes a parent callback, so `fetchDevices` is not re-issued and the row keeps the stale value (the list GET is called only once)
|
||||
- Document the counterexample found (e.g. "after saving resolution_date='2026-03-04', the list row still shows '—'; GET /compliance/items called 1 time, expected ≥ 2")
|
||||
- Mark this task complete when the test is written, run, and the failure is documented
|
||||
- _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2_
|
||||
|
||||
- [x] 2. Fix the stale list after sidebar metadata save
|
||||
|
||||
- [x] 2.1 Invoke a parent refresh callback from `handleSaveMetadata()` after a successful PATCH
|
||||
- In `frontend/src/components/pages/ComplianceDetailPanel.js`, add `onMetadataSaved` to the component props: `export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onMetadataSaved, onNavigate })`
|
||||
- In `handleSaveMetadata()`, after the `PATCH` succeeds and `await fetchDetail()` completes (inside the `try`, after the existing success handling), add: `if (onMetadataSaved) onMetadataSaved();`
|
||||
- Place the call so it only runs on success (not in the `catch`), mirroring how `handleAddNote()` calls `if (onNoteAdded) onNoteAdded();` only after a successful note save
|
||||
- Keep the existing `await fetchDetail()` call so the panel's own view/history still refreshes (do not replace it)
|
||||
- _Bug_Condition: isBugCondition(input) = successful PATCH /api/compliance/items/:hostname/metadata from the sidebar_
|
||||
- _Expected_Behavior: a parent refresh callback is invoked on save success so the parent list re-fetches (bugfix.md 2.1)_
|
||||
- _Preservation: only the success path is touched; the `catch` path that sets `metaError` is unchanged (3.3); `fetchDetail()` still runs (3.6)_
|
||||
- _Requirements: 2.1, 3.3, 3.6_
|
||||
|
||||
- [x] 2.2 Wire `onMetadataSaved` to `refresh` in `CompliancePage`
|
||||
- In `frontend/src/components/pages/CompliancePage.js`, at the `<ComplianceDetailPanel>` render site (around lines 696–702), add the prop `onMetadataSaved={refresh}` alongside the existing `onNoteAdded={refresh}`
|
||||
- Reuse the existing `refresh` function (`fetchSummary(activeTeam)` + `fetchDevices(activeTeam, activeTab)`) — do not introduce a new refresh path
|
||||
- Leave `onClose={() => setSelectedHost(null)}` unchanged (close still just clears selection)
|
||||
- Do not leave any unused variables; if a value is intentionally unused prefix it with `_` per the ESLint `no-unused-vars` warning budget
|
||||
- _Bug_Condition: isBugCondition(input) = successful sidebar metadata save while the panel is open from CompliancePage_
|
||||
- _Expected_Behavior: the parent list re-fetches via fetchDevices so the updated Resolution Date / Remediation Plan appears in the row without a manual refresh (bugfix.md 2.1, 2.2)_
|
||||
- _Preservation: onNoteAdded still maps to refresh (3.1); team/tab/upload/manual-refresh triggers unchanged (3.2); onClose unchanged (3.4); other row fields unchanged (3.5)_
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 2.3 Verify the bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - List Row Reflects Saved Value Without Manual Refresh
|
||||
- **IMPORTANT**: Re-run the SAME test from Task 1 — do NOT write a new test
|
||||
- The Task 1 test encodes the expected behavior; when it passes it confirms the fix satisfies Property 1
|
||||
- Run: `cd frontend && CI=true npm test -- --testPathPattern compliance-list-stale-after-sidebar-edit.exploration`
|
||||
- **EXPECTED OUTCOME**: Test PASSES — after a successful save the list re-fetches (`GET /compliance/items?...` called ≥ 2 times) and the row displays the saved value
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 3. Write preservation / regression property test
|
||||
- **Property 2: Preservation** - Unchanged Behaviors Hold After the Fix
|
||||
- **IMPORTANT**: Follow observation-first methodology — the unchanged behaviors below are observed to hold on UNFIXED code and must continue to hold after the fix; only the new "list updates after save" guard is expected to flip from failing to passing
|
||||
- **Test file**: `frontend/src/components/pages/__tests__/compliance-list-stale-after-sidebar-edit.preservation.property.test.js`
|
||||
- **Header comment**: reference spec `.kiro/specs/compliance-list-stale-after-sidebar-edit/` and issue #23 (mirrors the predecessor preservation test tagging convention)
|
||||
- **Test structure**: reuse the same `useAuth` mock and URL-routing `global.fetch` mock approach as Task 1; use `fast-check` to generate varied note text and metadata values across runs
|
||||
- **Regression guard (new behavior, post-fix)**: _for any_ saved metadata value, after a successful save the parent list row displays the saved value (same assertion family as Property 1, kept as a standing regression guard)
|
||||
- **Preservation properties (must hold before and after the fix)**:
|
||||
- **Note-add refresh still works**: when a note is added in the sidebar (`POST /compliance/notes` succeeds), the existing `onNoteAdded` callback still triggers a list re-fetch — assert `GET /compliance/items?...` is re-issued after a note add (bugfix.md 3.1)
|
||||
- **Failed save shows error and does NOT falsely update the list**: when `PATCH /compliance/items/:hostname/metadata` returns a non-OK response, assert the panel surfaces `metaError` AND the list row value is unchanged / no spurious list re-fetch displays a value that was never persisted (bugfix.md 3.3)
|
||||
- **Close-without-change just clears selection**: clicking the close (`X`) / backdrop without saving removes the detail panel (selection cleared) and does not error; no metadata save occurs (bugfix.md 3.4)
|
||||
- **Other row fields render correctly**: _for any_ generated device, the row still displays hostname, IP address, device type, failing metrics, and seen count unchanged (bugfix.md 3.5)
|
||||
- Run before the fix to confirm the preservation properties pass on unfixed code; run after the fix to confirm they still pass and the regression guard now passes
|
||||
- Command: `cd frontend && CI=true npm test -- --testPathPattern compliance-list-stale-after-sidebar-edit.preservation`
|
||||
- **EXPECTED OUTCOME (post-fix)**: All preservation properties PASS (no regressions) and the regression guard PASSES
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [x] 4. Checkpoint - Run tests and confirm the production build
|
||||
- Run both new tests together: `cd frontend && CI=true npm test -- --testPathPattern compliance-list-stale-after-sidebar-edit`
|
||||
- **EXPECTED OUTCOME**: exploration test PASSES (bug fixed), preservation test PASSES (no regressions)
|
||||
- Run the production build to confirm it compiles and stays within the ESLint 25-warning budget: `cd frontend && npm run build`
|
||||
- **EXPECTED OUTCOME**: build succeeds and the warning count remains at or below 25 (the CI `lint`/`build` stage fails above 25) — prefix any intentionally-unused variables with `_` if needed
|
||||
- Ensure all tests pass; ask the user if questions arise.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1"] },
|
||||
{ "id": 1, "tasks": ["2.1", "2.2"] },
|
||||
{ "id": 2, "tasks": ["2.3", "3"] },
|
||||
{ "id": 3, "tasks": ["4"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a frontend-only fix. The backend list endpoint (`GET /api/compliance/items`) already returns `resolution_date` and `remediation_plan` (predecessor spec `compliance-remediation-display-fix`), and `DeviceRow` in `CompliancePage.js` (lines 883–890) already renders them correctly when present.
|
||||
- The fix is intentionally minimal and mirrors the existing `onNoteAdded` → `refresh` pattern rather than introducing a new refresh mechanism, satisfying the Regression Prevention clauses (3.1–3.6).
|
||||
- Both tests render `CompliancePage` with a mocked `useAuth` and a URL-routing `global.fetch` mock; the list GET mock returns the stale value on first call and the saved value on later calls so a post-save re-fetch is observable in the rendered row.
|
||||
- `fast-check` is already allowed through `jest.transformIgnorePatterns` in `frontend/package.json`; generate dates as `YYYY-MM-DD` strings from integer tuples (avoid `toISOString` on shrunk values) as in the predecessor exploration test.
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "0962d00a-cfe6-4a5d-88c6-e9e6f220b1a0", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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.1–5.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 `1–12`, 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 100–200).
|
||||
- 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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature surfaces the estimated resolution date for each noncompliant metric directly at the top of that metric's section in the asset sidebar on the compliance page. Today, an asset's resolution date is only visible through the editable **Resolution Date** metadata field lower in the sidebar, where it is computed as a shared value across selected metrics and collapses to "Multiple values" when metrics disagree. This feature instead displays each metric's own `resolution_date` inline with the metric, so a viewer can see the projected compliance date (for example "2026-07-01") for metrics such as `2.3.6i`, `2.3.8i`, and `Vulns_Aging` without scrolling to or interacting with the metadata editor.
|
||||
|
||||
This is a read and display oriented feature. It surfaces the existing per-metric `resolution_date` value already tracked in the data model; it does not introduce new persistence, new editing surfaces, or changes to how the date is stored or computed.
|
||||
|
||||
Traceability: GitLab issue #20 — "[Feature Request] Show estimated resolution date in sidebar per metric" (http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/20).
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Compliance_Page**: The AEO Compliance page in the frontend that lists noncompliant assets and their metrics.
|
||||
- **Asset_Sidebar**: The detail panel rendered by `ComplianceDetailPanel.js` that opens for a single asset (hostname) and lists that asset's metrics, metadata, history, and notes.
|
||||
- **Metric**: A single AEO compliance check tracked for an asset, identified by a `metric_id` (for example `2.3.6i`, `2.3.8i`, `Vulns_Aging`). Each metric has a `status` of `active` (noncompliant) or `resolved` (compliant).
|
||||
- **Noncompliant_Metric**: A metric with `status` equal to `active`.
|
||||
- **Compliant_Metric**: A metric with `status` equal to `resolved`.
|
||||
- **Estimated_Resolution_Date**: The projected date by which a metric is expected to return to compliance, stored per metric in the `resolution_date` field and derived from the per-metric remediation notes/date metadata.
|
||||
- **Resolution_Date_Field**: The existing per-metric `resolution_date` data field returned by the compliance API for each metric.
|
||||
- **Viewer**: A user with read-only access to all data.
|
||||
- **Editor**: A user with viewer permissions plus create and update operations.
|
||||
- **Admin**: A user with editor permissions plus delete, user management, and audit log access.
|
||||
- **Team**: A compliance business unit. Only STEAM and ACCESS-ENG are tracked.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Display estimated resolution date per noncompliant metric
|
||||
|
||||
**User Story:** As a viewer reviewing an asset's compliance posture, I want to see each noncompliant metric's estimated resolution date at the top of that metric's section, so that I can understand the projected compliance date for each metric without scrolling to or interacting with the metadata editor.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Asset_Sidebar renders a Noncompliant_Metric whose Resolution_Date_Field contains a value parseable as a calendar date, THE Asset_Sidebar SHALL display that metric's Estimated_Resolution_Date within that metric's section.
|
||||
2. WHEN the Asset_Sidebar renders a Noncompliant_Metric whose Resolution_Date_Field contains a value parseable as a calendar date, THE Asset_Sidebar SHALL position that metric's Estimated_Resolution_Date at the top of that metric's section, above the metric description and above all other fields belonging to that metric.
|
||||
3. THE Asset_Sidebar SHALL display the Estimated_Resolution_Date for each Noncompliant_Metric using the value of that same metric's Resolution_Date_Field.
|
||||
4. THE Asset_Sidebar SHALL display the Estimated_Resolution_Date as a calendar date in `YYYY-MM-DD` format (four-digit year, two-digit month, and two-digit day separated by hyphens).
|
||||
5. WHEN the Asset_Sidebar displays a Noncompliant_Metric's Estimated_Resolution_Date, THE Asset_Sidebar SHALL render a visible text label adjacent to that value identifying it as the estimated resolution date.
|
||||
6. IF a Noncompliant_Metric's Resolution_Date_Field is non-empty but does not contain a value parseable as a calendar date, THEN THE Asset_Sidebar SHALL omit the Estimated_Resolution_Date value for that metric and display a placeholder indicating that no valid estimated resolution date is available, while continuing to render the remainder of that metric's section.
|
||||
|
||||
### Requirement 2: Handle metrics with no estimated resolution date
|
||||
|
||||
**User Story:** As a viewer, I want a clear indication when a noncompliant metric has no estimated resolution date set, so that I can distinguish metrics with a plan from metrics that still need one.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. IF a Noncompliant_Metric has a Resolution_Date_Field that is null, an empty string, or contains only whitespace, THEN THE Asset_Sidebar SHALL display, at the top of that metric's section, a text placeholder that identifies the value as the estimated resolution date and indicates that no estimated resolution date is set for that metric.
|
||||
2. WHEN the Asset_Sidebar opens for an asset, THE Asset_Sidebar SHALL render every Noncompliant_Metric for that asset regardless of whether that metric's Resolution_Date_Field is populated, null, empty, or whitespace-only.
|
||||
|
||||
### Requirement 3: Scope display to noncompliant metrics
|
||||
|
||||
**User Story:** As a viewer, I want the estimated resolution date emphasized only for noncompliant metrics, so that the sidebar focuses attention on metrics that still require remediation.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. IF a Metric is a Compliant_Metric (status = resolved), THEN THE Asset_Sidebar SHALL NOT display the top-of-section Estimated_Resolution_Date (positioned above the metric description and supplementary fields) for that metric, regardless of whether that metric has a populated Resolution_Date_Field.
|
||||
2. WHEN the Asset_Sidebar renders the set of Noncompliant_Metric entries, THE Asset_Sidebar SHALL derive each metric's Estimated_Resolution_Date using only that same metric's Resolution_Date_Field value.
|
||||
3. WHEN two Noncompliant_Metric entries have different Resolution_Date_Field values, THE Asset_Sidebar SHALL display each metric's own Estimated_Resolution_Date without collapsing the values into a combined or "Multiple values" representation.
|
||||
4. WHEN the Asset_Sidebar renders an asset containing both Compliant_Metric and Noncompliant_Metric entries, THE Asset_Sidebar SHALL display the top-of-section Estimated_Resolution_Date only within Noncompliant_Metric sections.
|
||||
|
||||
### Requirement 4: Preserve existing metadata editing behavior
|
||||
|
||||
**User Story:** As an editor, I want the existing resolution date editing workflow to continue functioning unchanged, so that surfacing the per-metric date for display does not regress my ability to update it.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Asset_Sidebar SHALL continue to render the existing editable Resolution Date metadata field.
|
||||
2. WHEN an Editor successfully saves an updated Resolution_Date_Field for a Noncompliant_Metric, THE Asset_Sidebar SHALL display the updated Estimated_Resolution_Date for that metric within 2 seconds of the successful save.
|
||||
3. THE Asset_Sidebar SHALL derive the displayed Estimated_Resolution_Date from the same per-metric Resolution_Date_Field used by the existing metadata editing workflow.
|
||||
4. IF an Editor's save of an updated Resolution_Date_Field fails, THEN THE Asset_Sidebar SHALL retain the previously displayed Estimated_Resolution_Date and display an error indication for the failed save.
|
||||
5. WHEN an Editor clears the Resolution_Date_Field for a Noncompliant_Metric and the save succeeds, THE Asset_Sidebar SHALL display the no-date placeholder for that metric consistent with Requirement 2.
|
||||
|
||||
### Requirement 5: Role-based access to the display
|
||||
|
||||
**User Story:** As a security stakeholder, I want the estimated resolution date display to honor existing role-based access controls, so that read-only users can see the information without gaining editing capability.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE the current user is a Viewer, THE Asset_Sidebar SHALL display each Noncompliant_Metric's Estimated_Resolution_Date using the same value and `YYYY-MM-DD` format presented to Editor and Admin users.
|
||||
2. WHILE the current user is an Editor or Admin, THE Asset_Sidebar SHALL display each Noncompliant_Metric's Estimated_Resolution_Date using the same value and `YYYY-MM-DD` format presented to a Viewer.
|
||||
3. THE Asset_Sidebar SHALL present the top-of-section Estimated_Resolution_Date display as read-only content that contains no input field, button, link, or other interactive control capable of modifying a Resolution_Date_Field, regardless of the current user's role.
|
||||
4. WHILE the current user is a Viewer, THE Asset_Sidebar SHALL present no enabled control that creates or updates a Resolution_Date_Field value.
|
||||
138
.kiro/specs/compliance-metric-estimated-resolution-date/tasks.md
Normal file
138
.kiro/specs/compliance-metric-estimated-resolution-date/tasks.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Implementation Plan: Compliance Metric Estimated Resolution Date
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements a read-only, per-metric estimated resolution date line at the top of each noncompliant metric's section in the asset sidebar (`ComplianceDetailPanel.js`). The work is frontend-only (React 19, plain JavaScript) and is built test-first: the pure date helper and its property-based tests come first, followed by the `MetricRow` rendering change and its render tests, then build and test verification.
|
||||
|
||||
All date logic is isolated in a pure helper (`frontend/src/utils/resolutionDate.js`) so it can be property- and example-tested independently of React. The component change adds a single read-only block to `MetricRow` and changes no prop signatures. The editable Resolution Date metadata `Section`, `computeSharedValues`, `handleSaveMetadata`, and the metadata PATCH flow are left unchanged.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Create the pure resolution-date helper module
|
||||
- [x] 1.1 Implement `formatResolutionDate` and display constants in `frontend/src/utils/resolutionDate.js`
|
||||
- Create `frontend/src/utils/resolutionDate.js` following the existing `frontend/src/utils/` pure-module pattern (for example `queueGrouping.js`)
|
||||
- Export `formatResolutionDate(raw)` returning the discriminated union `{ state: 'set', value } | { state: 'none' } | { state: 'invalid' }`
|
||||
- Return `{ state: 'none' }` for `null`, `undefined`, empty string, or whitespace-only (after `trim()`)
|
||||
- Return `{ state: 'set', value }` only when the trimmed value matches `^\d{4}-\d{2}-\d{2}$` AND is a real calendar date (month `1–12`, day within the month's true length, leap-year aware); `value` is the normalized `YYYY-MM-DD` string
|
||||
- Return `{ state: 'invalid' }` for any other non-empty value (wrong shape, `2026-13-01`, `2026-02-30`, arbitrary text)
|
||||
- Keep the function pure and deterministic: no React, no I/O, no system clock/timezone/locale dependency, and do NOT use `new Date(string)` for the validity decision
|
||||
- Export `RESOLUTION_DATE_LABEL = 'Est. Resolution'`, `NO_DATE_PLACEHOLDER = 'not set'`, and `INVALID_DATE_PLACEHOLDER = 'invalid date'` as the single source of truth for component and tests
|
||||
- _Requirements: 1.1, 1.4, 1.6, 2.1_
|
||||
|
||||
- [x]* 1.2 Write property test for valid calendar date classification and formatting
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.property.test.js`
|
||||
- Use `fast-check` (v4) with Jest (`react-scripts test`); do not hand-roll generators
|
||||
- **Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD**
|
||||
- Generator: valid calendar dates spanning years, all months, month-length boundaries (28/29/30/31), and leap days; assert `state === 'set'`, `value` matches `^\d{4}-\d{2}-\d{2}$` and equals the canonical normalized form
|
||||
- Run a minimum of 100 iterations (`{ numRuns: 100 }` or higher)
|
||||
- Tag the test: `// Feature: compliance-metric-estimated-resolution-date, Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD`
|
||||
- **Validates: Requirements 1.1, 1.4**
|
||||
|
||||
- [x]* 1.3 Write property test for absent values classifying as "none"
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.property.test.js`
|
||||
- **Property 2: Absent values classify as "none"**
|
||||
- Generator: `fc.constantFrom(null, undefined, '')` combined with whitespace-only strings built from spaces, tabs, and newlines of varying length; assert `state === 'none'`
|
||||
- Minimum 100 iterations
|
||||
- Tag the test: `// Feature: compliance-metric-estimated-resolution-date, Property 2: Absent values classify as "none"`
|
||||
- **Validates: Requirements 2.1, 4.5**
|
||||
|
||||
- [x]* 1.4 Write property test for non-calendar-date values classifying as "invalid"
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.property.test.js`
|
||||
- **Property 3: Non-empty non-calendar-date values classify as "invalid"**
|
||||
- Generator: non-empty, non-whitespace-only 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'`
|
||||
- Minimum 100 iterations
|
||||
- Tag the test: `// Feature: compliance-metric-estimated-resolution-date, Property 3: Non-empty non-calendar-date values classify as "invalid"`
|
||||
- **Validates: Requirements 1.6**
|
||||
|
||||
- [x]* 1.5 Write property test for total classification over any metric list
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.property.test.js`
|
||||
- **Property 4: Classification is total over any metric list**
|
||||
- Generator: arrays mixing all input categories (valid dates, `null`, empty, whitespace-only, malformed); assert `formatResolutionDate` never throws, each result's `state` is in `{ 'set', 'none', 'invalid' }`, and the result count equals the input length
|
||||
- Minimum 100 iterations
|
||||
- Tag the test: `// Feature: compliance-metric-estimated-resolution-date, Property 4: Classification is total over any metric list`
|
||||
- **Validates: Requirements 2.2**
|
||||
|
||||
- [x]* 1.6 Write property test for per-metric independence (no collapsing)
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.property.test.js`
|
||||
- **Property 5: Each metric's display derives only from its own field (no collapsing)**
|
||||
- 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` applied to that same metric's own `resolution_date` computed in isolation, and that no result is replaced by a shared/"Multiple values" sentinel
|
||||
- Minimum 100 iterations
|
||||
- Tag the test: `// Feature: compliance-metric-estimated-resolution-date, Property 5: Each metric's display derives only from its own field (no collapsing)`
|
||||
- **Validates: Requirements 1.3, 3.2, 3.3**
|
||||
|
||||
- [x]* 1.7 Write example and edge-case unit tests for the helper
|
||||
- File: `frontend/src/utils/__tests__/resolutionDate.test.js`
|
||||
- Concrete fixtures: `'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`
|
||||
- _Requirements: 1.1, 1.4, 1.6, 2.1_
|
||||
|
||||
- [x] 2. Checkpoint - Ensure helper tests pass
|
||||
- Run `cd frontend && CI=true npm test -- resolutionDate` to confirm the helper and its property tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Render the estimated-resolution-date line in `MetricRow`
|
||||
- [x] 3.1 Add the read-only date block to `MetricRow` in `frontend/src/components/pages/ComplianceDetailPanel.js`
|
||||
- Import `formatResolutionDate`, `RESOLUTION_DATE_LABEL`, `NO_DATE_PLACEHOLDER`, and `INVALID_DATE_PLACEHOLDER` from `../../utils/resolutionDate`
|
||||
- Render the new block as the first child of the row content, above the existing top row (`MetricChip`), the metric description, the Ivanti ID row, and the highlights list (Requirement 1.2)
|
||||
- Render the block only when `resolved` is falsy; for `resolved === true`, `MetricRow` must behave exactly as today (Requirements 3.1, 3.4)
|
||||
- For active metrics, call `formatResolutionDate(metric.resolution_date)` and render by state: `set` → label + `YYYY-MM-DD` value; `none` → label + `NO_DATE_PLACEHOLDER`; `invalid` → label + `INVALID_DATE_PLACEHOLDER`, with the rest of the row still rendering
|
||||
- Render `RESOLUTION_DATE_LABEL` as a visible text label adjacent to the value/placeholder (Requirement 1.5)
|
||||
- Use plain text only: no `input`, `button`, `a`, or change handler in the new subtree (Requirements 5.3, 5.4)
|
||||
- Do NOT change the `MetricRow` prop signature; read `resolution_date` from the existing `metric` object
|
||||
- Leave `computeSharedValues`, `handleSaveMetadata`, the editable Resolution Date metadata `Section`, and the metadata PATCH flow unchanged (Requirement 4.1)
|
||||
- Prefix any intentionally-unused variables with `_` per the project lint rules
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 3.1, 3.2, 3.3, 3.4, 4.1, 5.3, 5.4_
|
||||
|
||||
- [x]* 3.2 Write render tests for placement, labels, and placeholders
|
||||
- File: `frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js` 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): `RESOLUTION_DATE_LABEL` text appears adjacent to the value for an active row with a valid date
|
||||
- Set value (1.1, 1.4): an active row with `'2026-07-01'` renders `2026-07-01`
|
||||
- No-date placeholder (2.1, 4.5): active rows with `null`/empty/whitespace render `NO_DATE_PLACEHOLDER` and still render the metric description
|
||||
- Invalid placeholder (1.6): an active row with a malformed date renders `INVALID_DATE_PLACEHOLDER` and still renders the metric description
|
||||
- _Requirements: 1.1, 1.2, 1.4, 1.5, 1.6, 2.1, 4.5_
|
||||
|
||||
- [x]* 3.3 Write render tests for resolved suppression, read-only structure, and role-independence
|
||||
- File: `frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js`
|
||||
- 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
|
||||
- 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 introduces no editing control
|
||||
- Existing editor preserved (4.1): the panel still renders the editable Resolution Date `input[type=date]`
|
||||
- _Requirements: 3.1, 3.4, 4.1, 5.1, 5.2, 5.3, 5.4_
|
||||
|
||||
- [x]* 3.4 Write interaction tests for the existing save round-trip
|
||||
- File: `frontend/src/components/pages/__tests__/ComplianceDetailPanel.metricRow.test.js`
|
||||
- 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
|
||||
- _Requirements: 4.2, 4.3, 4.4, 4.5_
|
||||
|
||||
- [ ] 4. Final checkpoint - Build and test verification
|
||||
- [x] 4.1 Verify production build and ESLint budget
|
||||
- Run `cd frontend && npm run build` and confirm the build compiles
|
||||
- Confirm ESLint warnings stay within the 25-warning budget; prefix any intentionally-unused variables with `_`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 3.1, 3.2, 3.3, 3.4, 4.1_
|
||||
|
||||
- [x] 4.2 Run the full test suite
|
||||
- Run `cd frontend && CI=true npm test` (non-watch) to execute the new property and render tests alongside the existing suite
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional test sub-tasks and can be skipped for a faster MVP, but they validate the five correctness properties and the fixed-DOM acceptance criteria and are recommended.
|
||||
- Each task references specific requirements (and, for property tests, the design property number) for traceability.
|
||||
- Test-driven ordering: the pure helper and its property tests (task 1) come before the component change (task 3) so the only branching logic is validated first.
|
||||
- Property tests use `fast-check` with a minimum of 100 iterations and are tagged with their feature and property number per the design's Testing Strategy.
|
||||
- Checkpoints (tasks 2 and 4) ensure incremental validation and a clean production build within the lint budget.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "3.1"] },
|
||||
{ "id": 2, "tasks": ["3.2", "3.3", "3.4"] },
|
||||
{ "id": 3, "tasks": ["4.1"] },
|
||||
{ "id": 4, "tasks": ["4.2"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "bfe6410f-227a-4c38-8e56-e6e6fe3bab50", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||
41
.kiro/specs/compliance-remediation-display-fix/bugfix.md
Normal file
41
.kiro/specs/compliance-remediation-display-fix/bugfix.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Bugfix Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The compliance main table (CompliancePage.js) always displays "—" for the Resolution Date and Remediation Plan columns, even after a user edits these fields in the ComplianceDetailPanel. The detail panel correctly saves and reads per-device metadata via `PATCH /api/compliance/items/:hostname/metadata` and `GET /api/compliance/items/:hostname`, but the list endpoint `GET /api/compliance/items?team=X&status=Y` omits `resolution_date` and `remediation_plan` from its SELECT query, and the `groupByHostname()` helper does not propagate these fields into the device objects returned to the frontend.
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Current Behavior (Defect)
|
||||
|
||||
1.1 WHEN the main compliance table fetches devices via `GET /api/compliance/items?team=X&status=Y` THEN the system does not include `resolution_date` or `remediation_plan` in the SQL SELECT clause, so these columns are absent from the query result rows.
|
||||
|
||||
1.2 WHEN `groupByHostname()` constructs per-device objects from the query rows THEN the system does not include `resolution_date` or `remediation_plan` fields in the device objects, resulting in `undefined` values on the frontend.
|
||||
|
||||
1.3 WHEN the frontend renders the Resolution Date column using `device.resolution_date || '—'` THEN the system always displays "—" because the field is `undefined`.
|
||||
|
||||
1.4 WHEN the frontend renders the Remediation Plan column using `truncateText(device.remediation_plan)` THEN the system always displays "—" because the field is `undefined`.
|
||||
|
||||
### Expected Behavior (Correct)
|
||||
|
||||
2.1 WHEN the main compliance table fetches devices via `GET /api/compliance/items?team=X&status=Y` THEN the system SHALL include `ci.resolution_date` and `ci.remediation_plan` in the SQL SELECT clause so these values are present in the query result rows.
|
||||
|
||||
2.2 WHEN `groupByHostname()` constructs per-device objects from the query rows THEN the system SHALL include `resolution_date` and `remediation_plan` fields in each device object, aggregated as the first non-null value encountered across the hostname's metric rows (or null if all are null).
|
||||
|
||||
2.3 WHEN the frontend renders the Resolution Date column and `device.resolution_date` contains a valid date string THEN the system SHALL display that date value instead of "—".
|
||||
|
||||
2.4 WHEN the frontend renders the Remediation Plan column and `device.remediation_plan` contains a non-empty string THEN the system SHALL display the truncated plan text instead of "—".
|
||||
|
||||
### Unchanged Behavior (Regression Prevention)
|
||||
|
||||
3.1 WHEN the detail panel saves metadata via `PATCH /api/compliance/items/:hostname/metadata` THEN the system SHALL CONTINUE TO persist `resolution_date` and `remediation_plan` to the `compliance_items` table correctly.
|
||||
|
||||
3.2 WHEN the detail panel reads a single device via `GET /api/compliance/items/:hostname` THEN the system SHALL CONTINUE TO return `resolution_date` and `remediation_plan` for that device's metrics.
|
||||
|
||||
3.3 WHEN `groupByHostname()` processes rows for devices that have no `resolution_date` or `remediation_plan` set (all null) THEN the system SHALL CONTINUE TO return `null` for those fields, and the frontend SHALL CONTINUE TO display "—".
|
||||
|
||||
3.4 WHEN the VCL reporting endpoints read `resolution_date` from `compliance_items` for burndown forecasts THEN the system SHALL CONTINUE TO function identically, as this fix only affects the list endpoint's SELECT and the grouping helper.
|
||||
|
||||
3.5 WHEN the main compliance table displays hostname, IP address, device type, failing metrics, and seen count THEN the system SHALL CONTINUE TO display these fields correctly without any change in behavior.
|
||||
|
||||
3.6 WHEN per-metric metadata scoping is used (different resolution_date values per metric on the same hostname) THEN the system SHALL CONTINUE TO store per-metric values correctly; the list endpoint SHALL aggregate by selecting the first non-null value across the hostname's metrics for display purposes.
|
||||
111
.kiro/specs/compliance-remediation-display-fix/tasks.md
Normal file
111
.kiro/specs/compliance-remediation-display-fix/tasks.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Implementation Plan: Compliance Remediation Display Fix
|
||||
|
||||
## Overview
|
||||
|
||||
Fix the compliance main table to display Resolution Date and Remediation Plan values. The `GET /items` endpoint omits `ci.resolution_date` and `ci.remediation_plan` from its SELECT clause, and `groupByHostname()` does not propagate these fields into device objects. The frontend already renders them correctly when present. This is a backend-only fix: add the columns to the SELECT and update the grouping helper to aggregate them as first-non-null across each hostname's metric rows.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Write bug condition exploration test
|
||||
- **Property 1: Bug Condition** - Resolution Date and Remediation Plan Missing from GET /items Response
|
||||
- **CRITICAL**: This test MUST FAIL on unfixed code - failure confirms the bug exists
|
||||
- **DO NOT attempt to fix the test or the code when it fails**
|
||||
- **NOTE**: This test encodes the expected behavior - it will validate the fix when it passes after implementation
|
||||
- **GOAL**: Surface counterexamples that demonstrate the bug exists (resolution_date and remediation_plan are undefined in grouped device objects)
|
||||
- **Scoped PBT Approach**: Generate compliance_items rows where resolution_date and/or remediation_plan are non-null, pass them through groupByHostname(), and assert the output device objects contain those fields
|
||||
- **Bug Condition**: `isBugCondition(row) = row.resolution_date != null OR row.remediation_plan != null` — any row with metadata set will lose it through groupByHostname()
|
||||
- **Test file**: `backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js`
|
||||
- **Test structure**:
|
||||
- Use fast-check to generate arbitrary hostname strings, date strings for resolution_date, and non-empty strings for remediation_plan
|
||||
- Construct rows array with generated values present in resolution_date and remediation_plan columns
|
||||
- Call groupByHostname() with those rows
|
||||
- Assert: `device.resolution_date` equals the first non-null resolution_date across the hostname's rows
|
||||
- Assert: `device.remediation_plan` equals the first non-null remediation_plan across the hostname's rows
|
||||
- Run test on UNFIXED code
|
||||
- **EXPECTED OUTCOME**: Test FAILS because groupByHostname() does not propagate resolution_date or remediation_plan (they are undefined on the output objects)
|
||||
- Document counterexamples found (e.g., "groupByHostname([{hostname:'H1', resolution_date:'2025-06-01', ...}]) returns device with resolution_date === undefined")
|
||||
- Mark task complete when test is written, run, and failure is documented
|
||||
- _Requirements: 1.1, 1.2, 2.2_
|
||||
|
||||
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||
- **Property 2: Preservation** - Existing groupByHostname Fields Unchanged
|
||||
- **IMPORTANT**: Follow observation-first methodology
|
||||
- **Test file**: `backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js`
|
||||
- Observe: groupByHostname() correctly aggregates hostname, ip_address, device_type, team, status, failing_metrics, seen_count, first_seen, last_seen, resolved_on, has_notes on unfixed code
|
||||
- Observe: groupByHostname() deduplicates metrics by metric_id (no duplicate failing_metrics entries)
|
||||
- Observe: groupByHostname() picks the maximum seen_count across rows for the same hostname
|
||||
- Observe: groupByHostname() picks the earliest first_seen and latest last_seen across rows
|
||||
- **Test structure**:
|
||||
- Use fast-check to generate arrays of compliance rows with varying hostnames, metric_ids, seen_counts, first_seen/last_seen dates
|
||||
- All generated rows have resolution_date = null and remediation_plan = null (non-bug-condition inputs)
|
||||
- Call groupByHostname() and assert:
|
||||
- Property: each device.hostname appears exactly once in output
|
||||
- Property: device.failing_metrics contains no duplicate metric_ids
|
||||
- Property: device.seen_count >= every row's seen_count for that hostname
|
||||
- Property: device.first_seen <= every row's first_seen for that hostname
|
||||
- Property: device.last_seen >= every row's last_seen for that hostname
|
||||
- Property: device.has_notes matches noteHostnames membership
|
||||
- Verify test passes on UNFIXED code
|
||||
- **EXPECTED OUTCOME**: Tests PASS (confirms baseline behavior to preserve)
|
||||
- Mark task complete when tests are written, run, and passing on unfixed code
|
||||
- _Requirements: 3.3, 3.5_
|
||||
|
||||
- [x] 3. Fix for resolution_date and remediation_plan missing from compliance list endpoint
|
||||
|
||||
- [x] 3.1 Add resolution_date and remediation_plan to the GET /items SQL SELECT clause
|
||||
- In `backend/routes/compliance.js` around line 601, add `ci.resolution_date, ci.remediation_plan` to the SELECT column list
|
||||
- The columns already exist in the `compliance_items` table — they just need to be selected
|
||||
- _Bug_Condition: isBugCondition(row) = row.resolution_date != null OR row.remediation_plan != null — these columns are not selected so they are always absent_
|
||||
- _Expected_Behavior: The query result rows include resolution_date and remediation_plan values from the database_
|
||||
- _Preservation: All other selected columns remain unchanged; query WHERE/ORDER BY clauses unchanged_
|
||||
- _Requirements: 1.1, 2.1_
|
||||
|
||||
- [x] 3.2 Update groupByHostname() to propagate resolution_date and remediation_plan
|
||||
- In `backend/routes/compliance.js` around line 220, add `resolution_date: null, remediation_plan: null` to the initial device object
|
||||
- In the row iteration loop, aggregate as first-non-null: `if (row.resolution_date && !dev.resolution_date) dev.resolution_date = row.resolution_date;`
|
||||
- Same for remediation_plan: `if (row.remediation_plan && !dev.remediation_plan) dev.remediation_plan = row.remediation_plan;`
|
||||
- This matches the "first non-null value across the hostname's metric rows" aggregation strategy from the requirements
|
||||
- _Bug_Condition: isBugCondition(device) = device has rows with non-null resolution_date or remediation_plan but groupByHostname() drops them_
|
||||
- _Expected_Behavior: device.resolution_date = first non-null resolution_date across hostname's rows (or null if all null); same for remediation_plan_
|
||||
- _Preservation: All other device fields (hostname, ip_address, device_type, team, status, failing_metrics, seen_count, first_seen, last_seen, resolved_on, has_notes) remain unchanged_
|
||||
- _Requirements: 1.2, 2.2, 3.3, 3.5, 3.6_
|
||||
|
||||
- [x] 3.3 Verify bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - Resolution Date and Remediation Plan Present in GET /items Response
|
||||
- **IMPORTANT**: Re-run the SAME test from task 1 — do NOT write a new test
|
||||
- The test from task 1 encodes the expected behavior (resolution_date and remediation_plan propagated through groupByHostname)
|
||||
- When this test passes, it confirms the expected behavior is satisfied
|
||||
- Run: `npx jest backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js --run`
|
||||
- **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 3.4 Verify preservation tests still pass
|
||||
- **Property 2: Preservation** - Existing groupByHostname Fields Unchanged
|
||||
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||
- Run: `npx jest backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js --run`
|
||||
- **EXPECTED OUTCOME**: Tests PASS (confirms no regressions — existing field aggregation unchanged)
|
||||
- Confirm all tests still pass after fix (no regressions)
|
||||
|
||||
- [x] 4. Checkpoint - Ensure all tests pass
|
||||
- Run full test suite: `npx jest backend/__tests__/compliance-remediation-display-fix --run`
|
||||
- Verify no other compliance tests regressed: `npx jest backend/__tests__/compliance --run`
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1", "2"] },
|
||||
{ "id": 1, "tasks": ["3.1", "3.2"] },
|
||||
{ "id": 2, "tasks": ["3.3", "3.4"] },
|
||||
{ "id": 3, "tasks": ["4"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a backend-only fix. The frontend (CompliancePage.js lines 883-890) already renders resolution_date and remediation_plan correctly when the data is present.
|
||||
- The `groupByHostname()` function is a pure helper (no DB access), making it ideal for direct property-based testing without HTTP mocking.
|
||||
- The "first non-null" aggregation strategy handles per-metric metadata scoping: different metrics on the same hostname may have different resolution_date values, but the list view shows the first encountered non-null for display purposes.
|
||||
1
.kiro/specs/flexible-jira-ticket-creation/.config.kiro
Normal file
1
.kiro/specs/flexible-jira-ticket-creation/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "244dcf03-8538-4bc6-b47a-dde02022e4ab", "workflowType": "requirements-first", "specType": "feature"}
|
||||
370
.kiro/specs/flexible-jira-ticket-creation/design.md
Normal file
370
.kiro/specs/flexible-jira-ticket-creation/design.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Design Document: Flexible Jira Ticket Creation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature decouples Jira ticket creation from the CVE-only workflow by making CVE ID and Vendor optional, adding a `source_context` field to track ticket origin, and exposing "Create Jira Ticket" actions from the Ivanti queue and Archer detail views. The changes span a database migration, backend validation updates, and frontend modal/list enhancements.
|
||||
|
||||
The design preserves backward compatibility — existing tickets retain their CVE ID and Vendor values, and the new `source_context` column defaults to `manual` for legacy rows.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Frontend
|
||||
JiraPage[JiraPage.js<br/>Creation Modal + Ticket List]
|
||||
IvantiQueue[Ivanti Queue Page<br/>Create Jira Ticket action]
|
||||
ArcherView[App.js Archer Detail<br/>Create Jira Ticket action]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
Route[POST /api/jira-tickets/create-in-jira]
|
||||
Validation[Input Validation Layer]
|
||||
JiraAPI[jiraApi.js → Jira REST API]
|
||||
DB[(PostgreSQL<br/>jira_tickets table)]
|
||||
end
|
||||
|
||||
IvantiQueue -->|opens modal with pre-populated fields| JiraPage
|
||||
ArcherView -->|opens modal with pre-populated fields| JiraPage
|
||||
JiraPage -->|POST payload| Route
|
||||
Route --> Validation
|
||||
Validation -->|valid| JiraAPI
|
||||
JiraAPI -->|issue created| DB
|
||||
Validation -->|invalid| Route
|
||||
Route -->|400/201| JiraPage
|
||||
```
|
||||
|
||||
The modal component is reused across all three entry points (Jira page, Ivanti queue, Archer detail). Each entry point passes pre-populated field values and a locked `source_context` value to the modal.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: `POST /api/jira-tickets/create-in-jira` (Updated)
|
||||
|
||||
**Request Body:**
|
||||
|
||||
| Field | Type | Required | Validation |
|
||||
|---|---|---|---|
|
||||
| `summary` | string | Yes | Non-empty after trim, max 255 chars |
|
||||
| `cve_id` | string \| null | No | If present and non-empty, must match `CVE-YYYY-NNNN+`. Empty string treated as null. |
|
||||
| `vendor` | string \| null | No | If present and non-empty, 1–200 chars after trim. Empty string treated as null. |
|
||||
| `source_context` | string \| null | No | Must be one of: `cve`, `archer`, `ivanti_queue`, `email`, `manual`. Defaults to `manual` if absent. |
|
||||
| `description` | string \| null | No | Free text, passed to Jira issue body |
|
||||
| `project_key` | string \| null | No | Falls back to `JIRA_PROJECT_KEY` env var |
|
||||
| `issue_type` | string \| null | No | Falls back to `JIRA_ISSUE_TYPE` env var |
|
||||
|
||||
**Response (201):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"ticket_key": "VULN-789",
|
||||
"jira_url": "https://jira.example.com/browse/VULN-789",
|
||||
"source_context": "ivanti_queue",
|
||||
"message": "Jira issue created and linked successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Logic (pseudocode):**
|
||||
|
||||
```
|
||||
if cve_id is empty string → treat as null
|
||||
if cve_id is non-null and non-empty:
|
||||
if not matching /^CVE-\d{4}-\d{4,}$/ → 400
|
||||
if vendor is empty string or whitespace-only → treat as null
|
||||
if vendor is non-null and non-empty:
|
||||
trim whitespace
|
||||
if length > 200 → 400
|
||||
if source_context is present:
|
||||
if not in ALLOWED_SET → 400
|
||||
else:
|
||||
source_context = 'manual'
|
||||
if summary is empty or > 255 chars → 400
|
||||
```
|
||||
|
||||
### Backend: `PUT /api/jira-tickets/:id` (Updated)
|
||||
|
||||
The update endpoint rejects any attempt to change `source_context`:
|
||||
|
||||
```
|
||||
if request body contains source_context field → 400 "source_context is immutable after creation"
|
||||
```
|
||||
|
||||
### Backend: `GET /api/jira-tickets` (Updated)
|
||||
|
||||
Response now includes `source_context` on each ticket object. No other changes to the GET endpoint.
|
||||
|
||||
### Frontend: Creation Modal (Updated)
|
||||
|
||||
The existing `showCreateJira` modal in `JiraPage.js` is updated:
|
||||
|
||||
- CVE ID label: `"CVE ID (optional)"` with placeholder `"e.g. CVE-2024-12345"`
|
||||
- Vendor label: `"Vendor (optional)"` with placeholder `"e.g. Microsoft"`
|
||||
- New Source Context dropdown: options map display labels to API values
|
||||
- Summary remains required with inline validation error on empty submit
|
||||
- Form state extended with `source_context` field
|
||||
|
||||
**Source Context Options Mapping:**
|
||||
|
||||
| Display Label | API Value |
|
||||
|---|---|
|
||||
| CVE | `cve` |
|
||||
| Archer Request | `archer` |
|
||||
| Ivanti Queue | `ivanti_queue` |
|
||||
| Email | `email` |
|
||||
| Manual | `manual` |
|
||||
|
||||
When opened from Ivanti queue or Archer context, the source_context dropdown is pre-selected and read-only.
|
||||
|
||||
### Frontend: Ivanti Queue Integration
|
||||
|
||||
A "Create Jira Ticket" button is added to each queue item row (or action menu). When activated:
|
||||
|
||||
1. Opens the Creation Modal
|
||||
2. Pre-populates `summary` with `finding_title` (truncated to 255 chars)
|
||||
3. Pre-populates `cve_id` with first element of `cves_json` array (if non-empty)
|
||||
4. Pre-populates `vendor` with queue item's `vendor` value (if present)
|
||||
5. Sets `source_context` to `ivanti_queue` (locked)
|
||||
|
||||
### Frontend: Archer Detail Integration
|
||||
|
||||
A "Create Jira Ticket" button is added to the Archer ticket detail view (visible to editor/admin roles). When activated:
|
||||
|
||||
1. Opens the Creation Modal
|
||||
2. Pre-populates `summary` with `exc_number` (e.g., "EXC-1234")
|
||||
3. Pre-populates `cve_id` with Archer ticket's `cve_id` (if present)
|
||||
4. Pre-populates `vendor` with Archer ticket's `vendor` (if present)
|
||||
5. Sets `source_context` to `archer` (locked)
|
||||
|
||||
### Frontend: Ticket List Updates
|
||||
|
||||
The ticket list table gains:
|
||||
|
||||
- A new "Source" column between Vendor and Summary, displaying a color-coded badge
|
||||
- A source_context dropdown filter (matching the existing status filter pattern)
|
||||
- Search includes `source_context` in the filterable fields
|
||||
|
||||
**Badge Color Mapping:**
|
||||
|
||||
| Source Context | Badge Color |
|
||||
|---|---|
|
||||
| `cve` | `#0EA5E9` (blue) |
|
||||
| `archer` | `#8B5CF6` (purple) |
|
||||
| `ivanti_queue` | `#F59E0B` (amber) |
|
||||
| `email` | `#10B981` (green) |
|
||||
| `manual` | `#94A3B8` (gray) |
|
||||
|
||||
Null/empty source_context displays as "CVE" badge (blue) for backward compatibility with legacy tickets.
|
||||
|
||||
## Data Models
|
||||
|
||||
### `jira_tickets` Table (Updated Schema)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cve_id TEXT, -- Changed: NULL allowed
|
||||
vendor TEXT, -- Changed: NULL allowed
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')),
|
||||
source_context TEXT DEFAULT 'manual', -- New column
|
||||
jira_id TEXT,
|
||||
jira_status TEXT,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- `source_context` has a CHECK constraint limiting values to `('cve', 'archer', 'ivanti_queue', 'email', 'manual')`
|
||||
- `cve_id` allows NULL (NOT NULL constraint dropped)
|
||||
- `vendor` allows NULL (NOT NULL constraint dropped)
|
||||
|
||||
### Migration: `add_flexible_jira_ticket_creation.js`
|
||||
|
||||
```javascript
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting flexible Jira ticket creation migration...');
|
||||
|
||||
// Verify table exists
|
||||
const { rows } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'jira_tickets'
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Drop NOT NULL on cve_id
|
||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
|
||||
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
|
||||
|
||||
// Drop NOT NULL on vendor
|
||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
|
||||
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
|
||||
|
||||
// Add source_context column with default
|
||||
await pool.query(`
|
||||
ALTER TABLE jira_tickets
|
||||
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
|
||||
`);
|
||||
console.log('✓ source_context column added (or already exists)');
|
||||
|
||||
// Add CHECK constraint (idempotent via IF NOT EXISTS pattern)
|
||||
await pool.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
|
||||
) THEN
|
||||
ALTER TABLE jira_tickets
|
||||
ADD CONSTRAINT jira_tickets_source_context_check
|
||||
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
console.log('✓ source_context CHECK constraint added (or already exists)');
|
||||
|
||||
// Backfill existing rows (DEFAULT handles this, but explicit for clarity)
|
||||
await pool.query(`
|
||||
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
|
||||
`);
|
||||
console.log('✓ Existing rows backfilled with source_context = manual');
|
||||
|
||||
// Index for filtering
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
|
||||
ON jira_tickets(source_context)
|
||||
`);
|
||||
console.log('✓ source_context index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
**Idempotency:** `ALTER COLUMN DROP NOT NULL` is safe to run multiple times (no-op if already nullable). `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are inherently idempotent. The CHECK constraint uses a `DO $$ ... IF NOT EXISTS` guard.
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: CVE ID validation and storage
|
||||
|
||||
*For any* create-ticket request payload, if `cve_id` is absent, null, or an empty string, the service SHALL accept the request and store NULL for `cve_id`; if `cve_id` is a non-empty string matching `CVE-YYYY-NNNN+`, the service SHALL store that exact value.
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 1.4**
|
||||
|
||||
### Property 2: Invalid CVE ID rejection
|
||||
|
||||
*For any* non-empty string that does not match the pattern `CVE-YYYY-NNNN+` (four-digit year, four or more digit sequence), the service SHALL reject the create-ticket request with HTTP 400.
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 3: Vendor validation and storage
|
||||
|
||||
*For any* create-ticket request payload, if `vendor` is absent, null, empty, or whitespace-only, the service SHALL store NULL; if `vendor` is a non-empty string of 1–200 characters after trimming, the service SHALL store the trimmed value.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
### Property 4: Over-length vendor rejection
|
||||
|
||||
*For any* string that, after trimming whitespace, exceeds 200 characters, the service SHALL reject the create-ticket request with a validation error.
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 5: Invalid source_context rejection
|
||||
|
||||
*For any* string not in the set `{cve, archer, ivanti_queue, email, manual}`, the service SHALL reject the create-ticket request with HTTP 400 when that string is provided as `source_context`.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 6: source_context round-trip persistence
|
||||
|
||||
*For any* valid `source_context` value in `{cve, archer, ivanti_queue, email, manual}`, creating a ticket with that value and then fetching the ticket via GET SHALL return the same `source_context` value.
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
### Property 7: source_context immutability
|
||||
|
||||
*For any* existing ticket and any `source_context` value (valid or invalid), an update request that includes a `source_context` field SHALL be rejected with HTTP 400.
|
||||
|
||||
**Validates: Requirements 3.6**
|
||||
|
||||
### Property 8: Summary pre-population truncation from Ivanti queue
|
||||
|
||||
*For any* Ivanti queue item with a `finding_title` of arbitrary length, the pre-populated summary in the Creation Modal SHALL be at most 255 characters and SHALL equal the first 255 characters of `finding_title`.
|
||||
|
||||
**Validates: Requirements 5.2**
|
||||
|
||||
### Property 9: Search includes source_context
|
||||
|
||||
*For any* ticket whose `source_context` value contains the search term as a substring, that ticket SHALL appear in the filtered results when the user searches by that term.
|
||||
|
||||
**Validates: Requirements 8.4**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | HTTP Status | Error Message | Client Behavior |
|
||||
|---|---|---|---|
|
||||
| Invalid CVE ID format | 400 | "CVE ID format is invalid. Expected CVE-YYYY-NNNN+." | Display inline error, preserve form |
|
||||
| Vendor exceeds 200 chars | 400 | "Vendor exceeds maximum length of 200 characters." | Display inline error, preserve form |
|
||||
| Invalid source_context | 400 | "source_context must be one of: cve, archer, ivanti_queue, email, manual." | Display inline error, preserve form |
|
||||
| source_context update attempt | 400 | "source_context is immutable after creation." | Display inline error |
|
||||
| Empty summary | 400 | "Summary is required (max 255 chars)." | Prevent submission, show inline error |
|
||||
| Jira API unavailable | 502 | "Failed to create Jira issue." | Display error banner, preserve form values for retry |
|
||||
| Jira rate limit exceeded | 429 | "Jira rate limit exceeded. Try again later." | Display error banner, preserve form values |
|
||||
| Jira API not configured | 503 | "Jira API is not configured." | Display error banner |
|
||||
| DB insert fails after Jira creation | 207 | Warning with Jira key/URL | Display warning with Jira link |
|
||||
| jira_tickets table missing (migration) | Exit code 1 | Console error | Migration aborts cleanly |
|
||||
|
||||
**Error preservation:** When the Jira API call fails or returns an error, the frontend modal retains all user-entered form data so the user can retry without re-entering information. This applies to all three entry points (Jira page, Ivanti queue, Archer detail).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
- Modal renders with "(optional)" labels and correct placeholders
|
||||
- Modal allows submission with empty CVE ID and Vendor
|
||||
- Source Context dropdown contains all five options with no default selection
|
||||
- Source Context dropdown sends correct API value for each display label
|
||||
- Summary validation prevents empty submission with inline error
|
||||
- Ivanti queue "Create Jira Ticket" button is visible on queue items
|
||||
- Archer detail "Create Jira Ticket" button visible for editor/admin, hidden for viewer
|
||||
- Archer pre-populates summary with `exc_number`
|
||||
- Ticket list displays source_context badge between Vendor and Summary columns
|
||||
- Null source_context displays "CVE" badge text
|
||||
- Source context filter dropdown includes "All" and all distinct values
|
||||
- API error preserves form field values in modal
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based testing is appropriate for this feature because the backend validation logic operates on a wide input space (arbitrary strings for CVE IDs, vendors, and source_context values) where universal properties must hold.
|
||||
|
||||
**Library:** fast-check (already available in the project's test infrastructure via Jest)
|
||||
|
||||
**Configuration:**
|
||||
- Minimum 100 iterations per property test
|
||||
- Each test tagged with: `Feature: flexible-jira-ticket-creation, Property {N}: {title}`
|
||||
|
||||
Properties 1–7 test backend validation logic (pure input → output behavior, mockable Jira API).
|
||||
Property 8 tests frontend pre-population logic (pure string truncation).
|
||||
Property 9 tests frontend filtering logic (pure array filter).
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Migration runs successfully on a database with existing `jira_tickets` rows, preserving all data
|
||||
- Migration is idempotent (running twice produces same result)
|
||||
- Migration fails gracefully when `jira_tickets` table doesn't exist
|
||||
- End-to-end: create ticket from Ivanti queue context → verify stored with `source_context = 'ivanti_queue'`
|
||||
- End-to-end: create ticket from Archer context → verify stored with `source_context = 'archer'`
|
||||
117
.kiro/specs/flexible-jira-ticket-creation/requirements.md
Normal file
117
.kiro/specs/flexible-jira-ticket-creation/requirements.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The STEAM Security Dashboard's Jira ticket creation flow currently requires a CVE ID and Vendor for every ticket. This locks the workflow to vulnerability-driven contexts only. In practice, the team creates Jira work items from multiple sources — Archer risk acceptance requests, email-driven tasks, and Ivanti queue items — that may not relate to a specific CVE. This feature makes CVE ID and Vendor optional, adds a source context field to track where a ticket originated, and exposes a "Create Jira Ticket" action from the Ivanti queue and Archer ticket views.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Ticket_Creation_Service**: The backend endpoint (`POST /api/jira-tickets/create-in-jira`) responsible for creating issues in Jira and storing the local record.
|
||||
- **Jira_Tickets_Table**: The PostgreSQL `jira_tickets` table storing local records of Jira issues.
|
||||
- **Creation_Modal**: The frontend modal dialog used to create a new Jira ticket.
|
||||
- **Source_Context**: A metadata field indicating where a Jira ticket originated (e.g., `cve`, `archer`, `ivanti_queue`, `email`, `manual`).
|
||||
- **Ivanti_Queue_Page**: The frontend page where users work through their Ivanti finding queue items.
|
||||
- **Archer_Detail_View**: The frontend view displaying Archer ticket details.
|
||||
- **Dashboard**: The STEAM Security Dashboard application.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Make CVE ID Optional for Ticket Creation
|
||||
|
||||
**User Story:** As a security analyst, I want to create Jira tickets without providing a CVE ID, so that I can track work items that originate from non-vulnerability contexts.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a create-ticket request is submitted without a cve_id field or with cve_id set to null, THE Ticket_Creation_Service SHALL accept the request, create the Jira issue, store the local ticket record with a NULL cve_id, and return an HTTP 201 response.
|
||||
2. WHEN a create-ticket request includes a cve_id matching the pattern CVE-YYYY-NNNN+ (four-digit year, four or more digit sequence number), THE Ticket_Creation_Service SHALL store the cve_id on the local ticket record.
|
||||
3. IF a create-ticket request includes a cve_id that is a non-empty string not matching the pattern CVE-YYYY-NNNN+ (four-digit year, four or more digit sequence number), THEN THE Ticket_Creation_Service SHALL reject the request with an HTTP 400 response and an error message indicating the CVE ID format is invalid.
|
||||
4. IF a create-ticket request includes a cve_id that is an empty string, THEN THE Ticket_Creation_Service SHALL treat it as absent (NULL) and accept the request.
|
||||
5. THE Jira_Tickets_Table SHALL allow NULL values in the cve_id column.
|
||||
|
||||
### Requirement 2: Make Vendor Optional for Ticket Creation
|
||||
|
||||
**User Story:** As a security analyst, I want to create Jira tickets without providing a Vendor, so that I can create work items for tasks that are not vendor-specific.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a create-ticket request is submitted with the vendor field absent, null, or an empty string, THE Ticket_Creation_Service SHALL accept the request and create the Jira issue with a NULL vendor value stored in the local ticket record.
|
||||
2. WHEN a create-ticket request includes a vendor that is a non-empty string of 1 to 200 characters (after trimming whitespace), THE Ticket_Creation_Service SHALL store the trimmed vendor value on the local ticket record.
|
||||
3. IF a create-ticket request includes a vendor that, after trimming, is longer than 200 characters, THEN THE Ticket_Creation_Service SHALL reject the request with a validation error indicating the vendor exceeds the maximum length.
|
||||
4. THE Jira_Tickets_Table SHALL allow NULL values in the vendor column.
|
||||
5. WHEN a create-ticket request is submitted without a vendor, THE Ticket_Creation_Service SHALL NOT send the vendor value to the Jira API and SHALL still create the Jira issue successfully.
|
||||
|
||||
### Requirement 3: Add Source Context Tracking
|
||||
|
||||
**User Story:** As a security analyst, I want each Jira ticket to record where it was created from, so that I can understand the origin of work items when reviewing the ticket list.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Jira_Tickets_Table SHALL include a source_context column that accepts one of: `cve`, `archer`, `ivanti_queue`, `email`, `manual`.
|
||||
2. WHEN a create-ticket request does not include a source_context value, THE Ticket_Creation_Service SHALL default source_context to `manual`.
|
||||
3. IF a create-ticket request includes a source_context value not in the allowed set, THEN THE Ticket_Creation_Service SHALL reject the request with an HTTP 400 response containing an error message indicating the provided value is not in the allowed set.
|
||||
4. THE Ticket_Creation_Service SHALL store the source_context value on the local ticket record and return it in all GET responses that include ticket data.
|
||||
5. WHEN the source_context column is added, THE Migration SHALL set existing rows to `manual` so that no ticket has a null source_context value.
|
||||
6. IF an update-ticket request includes a source_context value, THEN THE Ticket_Creation_Service SHALL reject the change with an HTTP 400 response containing an error message indicating that source_context is immutable after creation.
|
||||
|
||||
### Requirement 4: Update the Creation Modal for Optional Fields
|
||||
|
||||
**User Story:** As a security analyst, I want the Create Jira Ticket modal to clearly indicate that CVE ID and Vendor are optional, so that I am not blocked from creating tickets without them.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Creation_Modal SHALL display CVE ID and Vendor fields each with a label containing the text "(optional)" and placeholder text indicating example input rather than a required value.
|
||||
2. THE Creation_Modal SHALL allow submission when CVE ID and Vendor are both empty.
|
||||
3. THE Creation_Modal SHALL include a Source Context selector with the options: CVE, Archer Request, Ivanti Queue, Email, Manual, and no option pre-selected by default.
|
||||
4. WHEN the user selects a source context, THE Creation_Modal SHALL send the selected option's corresponding value (`cve`, `archer`, `ivanti_queue`, `email`, `manual`) in the create request payload.
|
||||
5. THE Creation_Modal SHALL require Summary as a mandatory field with a maximum length of 255 characters.
|
||||
6. IF the user attempts to submit with an empty Summary, THEN THE Creation_Modal SHALL prevent submission and display an inline error message indicating that Summary is required.
|
||||
7. IF the user does not select a source context, THEN THE Creation_Modal SHALL omit the source_context field from the create request payload, allowing the backend to default to `manual`.
|
||||
|
||||
### Requirement 5: Create Jira Tickets from Ivanti Queue Context
|
||||
|
||||
**User Story:** As a security analyst, I want to create a Jira ticket directly from an Ivanti queue item, so that I can escalate findings to Jira without navigating away from my workflow.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE viewing an Ivanti queue item, THE Ivanti_Queue_Page SHALL display a "Create Jira Ticket" action.
|
||||
2. WHEN the user activates the "Create Jira Ticket" action on a queue item, THE Ivanti_Queue_Page SHALL open the Creation_Modal with the Summary field pre-populated with the queue item's finding_title (truncated to 255 characters) and source_context set to `ivanti_queue`.
|
||||
3. WHEN the queue item has a non-empty cves_json array, THE Creation_Modal SHALL pre-populate the CVE ID field with the first element of the cves_json array.
|
||||
4. IF the queue item has an empty or null cves_json value, THEN THE Creation_Modal SHALL leave the CVE ID field blank.
|
||||
5. WHEN the queue item has a vendor value, THE Creation_Modal SHALL pre-populate the Vendor field with that value.
|
||||
6. IF the Jira API is unavailable or returns an error when the user submits the Creation_Modal, THEN THE system SHALL display an error indication describing the failure and preserve the form field values so the user can retry without re-entering data.
|
||||
|
||||
### Requirement 6: Create Jira Tickets from Archer Ticket Context
|
||||
|
||||
**User Story:** As a security analyst, I want to create a Jira ticket from an Archer ticket view, so that I can create related Jira work items for risk acceptance exceptions.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE viewing Archer ticket details, IF the user has editor or admin role, THEN THE Archer_Detail_View SHALL display a "Create Jira Ticket" action.
|
||||
2. WHEN the user activates the "Create Jira Ticket" action on an Archer ticket, THE Archer_Detail_View SHALL open the Creation_Modal with the summary field pre-populated with the Archer ticket exc_number (e.g., "EXC-1234") and source_context set to `archer`.
|
||||
3. IF the Archer ticket has an associated CVE ID, THEN THE Creation_Modal SHALL pre-populate the CVE ID field with that value.
|
||||
4. IF the Archer ticket has an associated vendor, THEN THE Creation_Modal SHALL pre-populate the Vendor field with that value.
|
||||
5. IF the Jira ticket creation API call fails, THEN THE Creation_Modal SHALL display an error message indicating the failure reason and SHALL retain all user-entered form data.
|
||||
|
||||
### Requirement 7: Database Migration for Schema Changes
|
||||
|
||||
**User Story:** As a database administrator, I want the schema changes applied via a migration, so that existing deployments can upgrade without data loss.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL include a migration that alters the jira_tickets table to allow NULL in the cve_id column by dropping the NOT NULL constraint.
|
||||
2. THE Dashboard SHALL include a migration that alters the jira_tickets table to allow NULL in the vendor column by dropping the NOT NULL constraint.
|
||||
3. THE Dashboard SHALL include a migration that adds a source_context TEXT column to the jira_tickets table with a default value of `manual`, where existing rows receive the default value upon column addition.
|
||||
4. WHEN the migration runs on a database with existing jira_tickets rows, THE migration SHALL preserve all existing row values in the cve_id, vendor, ticket_key, url, summary, status, created_at, and updated_at columns without modification.
|
||||
5. THE migration SHALL be idempotent — running it multiple times SHALL produce the same schema state and data as running it once, without raising errors on subsequent executions.
|
||||
6. IF the jira_tickets table does not exist when the migration runs, THEN THE migration SHALL exit with an error message indicating the table is missing and no schema changes were applied.
|
||||
|
||||
### Requirement 8: Display Source Context in Ticket List
|
||||
|
||||
**User Story:** As a security analyst, I want to see the source context of each ticket in the Jira tickets list, so that I can quickly identify where work items originated.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL display the source_context value as a badge on each ticket row in the Jira tickets list view, positioned as a column between the VENDOR and SUMMARY columns.
|
||||
2. IF a ticket has no source_context value (null or empty string), THEN THE Dashboard SHALL display "CVE" as the default badge text since all legacy tickets were CVE-linked.
|
||||
3. THE Dashboard SHALL provide a dropdown filter for source_context values, consistent with the existing status filter dropdown, that includes an "All" option showing all tickets and one option per distinct source_context value present in the ticket data.
|
||||
4. WHEN the user enters a search term in the ticket search input, THE Dashboard SHALL include the source_context value in the searchable fields alongside ticket_key, cve_id, vendor, and summary.
|
||||
139
.kiro/specs/flexible-jira-ticket-creation/tasks.md
Normal file
139
.kiro/specs/flexible-jira-ticket-creation/tasks.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Implementation Plan: Flexible Jira Ticket Creation
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements flexible Jira ticket creation by making CVE ID and Vendor optional, adding source_context tracking, updating the creation modal, exposing "Create Jira Ticket" actions from Ivanti queue and Archer detail views, and updating the ticket list with source context display and filtering. The implementation proceeds from database migration → backend validation → frontend modal updates → integration points → list view enhancements.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Database migration for schema changes
|
||||
- [x] 1.1 Create migration file `backend/migrations/add_flexible_jira_ticket_creation.js`
|
||||
- Drop NOT NULL constraint on `cve_id` column
|
||||
- Drop NOT NULL constraint on `vendor` column
|
||||
- Add `source_context TEXT DEFAULT 'manual'` column with `IF NOT EXISTS`
|
||||
- Add CHECK constraint for allowed source_context values (`cve`, `archer`, `ivanti_queue`, `email`, `manual`) with idempotent guard
|
||||
- Backfill existing rows with `source_context = 'manual'` where NULL
|
||||
- Add index on `source_context` column
|
||||
- Verify `jira_tickets` table exists before proceeding; exit with error if missing
|
||||
- Ensure full idempotency — safe to run multiple times
|
||||
- _Requirements: 1.5, 2.4, 3.1, 3.5, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [x] 2. Update backend validation and creation endpoint
|
||||
- [x] 2.1 Update `POST /api/jira-tickets/create-in-jira` validation logic in `backend/routes/jiraTickets.js`
|
||||
- Make `cve_id` optional: accept absent, null, or empty string as NULL; validate format `CVE-YYYY-NNNN+` only when non-empty
|
||||
- Make `vendor` optional: accept absent, null, empty, or whitespace-only as NULL; trim and validate max 200 chars when non-empty
|
||||
- Add `source_context` validation: must be in allowed set if provided, default to `manual` if absent
|
||||
- Update INSERT query to include `source_context` column
|
||||
- Update 201 response to include `source_context` in returned JSON
|
||||
- Update audit log details to include `source_context`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.5, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 2.2 Update `PUT /api/jira-tickets/:id` to reject source_context changes
|
||||
- If request body contains `source_context` field, return 400 with "source_context is immutable after creation"
|
||||
- _Requirements: 3.6_
|
||||
|
||||
- [x] 2.3 Update `GET /api/jira-tickets` to include source_context in response
|
||||
- Ensure `source_context` is included in SELECT query and returned in ticket objects
|
||||
- _Requirements: 3.4_
|
||||
|
||||
- [ ]* 2.4 Write property tests for CVE ID validation (Property 1 and Property 2)
|
||||
- **Property 1: CVE ID validation and storage** — For any payload with absent/null/empty cve_id, service accepts and stores NULL; for valid CVE format, stores exact value
|
||||
- **Property 2: Invalid CVE ID rejection** — For any non-empty string not matching `CVE-YYYY-NNNN+`, service rejects with 400
|
||||
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4**
|
||||
- File: `backend/__tests__/jira-flexible-cve-validation.property.test.js`
|
||||
|
||||
- [ ]* 2.5 Write property tests for Vendor validation (Property 3 and Property 4)
|
||||
- **Property 3: Vendor validation and storage** — For any payload with absent/null/empty/whitespace vendor, stores NULL; for 1–200 char string after trim, stores trimmed value
|
||||
- **Property 4: Over-length vendor rejection** — For any string exceeding 200 chars after trim, service rejects with validation error
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3**
|
||||
- File: `backend/__tests__/jira-flexible-vendor-validation.property.test.js`
|
||||
|
||||
- [ ]* 2.6 Write property tests for source_context validation (Property 5, Property 6, Property 7)
|
||||
- **Property 5: Invalid source_context rejection** — For any string not in allowed set, service rejects with 400
|
||||
- **Property 6: source_context round-trip persistence** — For any valid source_context, creating then fetching returns same value
|
||||
- **Property 7: source_context immutability** — For any existing ticket, update with source_context field is rejected with 400
|
||||
- **Validates: Requirements 3.3, 3.4, 3.6**
|
||||
- File: `backend/__tests__/jira-flexible-source-context.property.test.js`
|
||||
|
||||
- [x] 3. Checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Update frontend Creation Modal for optional fields
|
||||
- [x] 4.1 Update the create Jira ticket modal in `frontend/src/components/pages/JiraPage.js`
|
||||
- Change CVE ID label to "CVE ID (optional)" with placeholder "e.g. CVE-2024-12345"
|
||||
- Change Vendor label to "Vendor (optional)" with placeholder "e.g. Microsoft"
|
||||
- Add Source Context dropdown with options: CVE → `cve`, Archer Request → `archer`, Ivanti Queue → `ivanti_queue`, Email → `email`, Manual → `manual`; no default selection
|
||||
- Allow form submission when CVE ID and Vendor are both empty
|
||||
- Keep Summary as required with inline error on empty submit (max 255 chars)
|
||||
- Send `source_context` in payload only when selected; omit if no selection (backend defaults to `manual`)
|
||||
- Support pre-populated field values and locked source_context (read-only when set externally)
|
||||
- Preserve form field values on API error for retry
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 5.6, 6.5_
|
||||
|
||||
- [x] 5. Add "Create Jira Ticket" action from Ivanti Queue
|
||||
- [x] 5.1 Add "Create Jira Ticket" button to Ivanti queue items in `frontend/src/components/pages/IvantiTodoQueuePage.js`
|
||||
- Add button/action to each queue item row
|
||||
- On click, open Creation Modal with: summary pre-populated from `finding_title` (truncated to 255 chars), source_context locked to `ivanti_queue`, cve_id from first element of `cves_json` (if non-empty), vendor from queue item's vendor (if present)
|
||||
- Leave CVE ID blank if `cves_json` is empty or null
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
|
||||
- [ ]* 5.2 Write property test for summary truncation (Property 8)
|
||||
- **Property 8: Summary pre-population truncation from Ivanti queue** — For any finding_title of arbitrary length, pre-populated summary is at most 255 chars and equals first 255 chars of finding_title
|
||||
- **Validates: Requirements 5.2**
|
||||
- File: `backend/__tests__/jira-flexible-summary-truncation.property.test.js`
|
||||
|
||||
- [x] 6. Add "Create Jira Ticket" action from Archer Detail View
|
||||
- [x] 6.1 Add "Create Jira Ticket" button to Archer ticket detail view in `frontend/src/components/pages/ArcherPage.js`
|
||||
- Show button only for users with editor or admin role
|
||||
- On click, open Creation Modal with: summary pre-populated with `exc_number` (e.g., "EXC-1234"), source_context locked to `archer`, cve_id from Archer ticket's cve_id (if present), vendor from Archer ticket's vendor (if present)
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
|
||||
- [x] 7. Update ticket list with source context display and filtering
|
||||
- [x] 7.1 Add source context badge column to ticket list in `frontend/src/components/pages/JiraPage.js`
|
||||
- Add "Source" column between Vendor and Summary columns
|
||||
- Display color-coded badge per source_context value: cve → blue (#0EA5E9), archer → purple (#8B5CF6), ivanti_queue → amber (#F59E0B), email → green (#10B981), manual → gray (#94A3B8)
|
||||
- Display "CVE" badge (blue) for null/empty source_context (legacy tickets)
|
||||
- _Requirements: 8.1, 8.2_
|
||||
|
||||
- [x] 7.2 Add source context dropdown filter to ticket list
|
||||
- Add dropdown filter consistent with existing status filter pattern
|
||||
- Include "All" option showing all tickets plus one option per distinct source_context value
|
||||
- _Requirements: 8.3_
|
||||
|
||||
- [x] 7.3 Include source_context in ticket search
|
||||
- Add source_context to the set of searchable fields alongside ticket_key, cve_id, vendor, and summary
|
||||
- _Requirements: 8.4_
|
||||
|
||||
- [ ]* 7.4 Write property test for search includes source_context (Property 9)
|
||||
- **Property 9: Search includes source_context** — For any ticket whose source_context contains the search term as a substring, that ticket appears in filtered results
|
||||
- **Validates: Requirements 8.4**
|
||||
- File: `backend/__tests__/jira-flexible-search-source-context.property.test.js`
|
||||
|
||||
- [x] 8. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The migration must run before backend changes are tested against a real database
|
||||
- The Creation Modal is shared across all three entry points (Jira page, Ivanti queue, Archer detail) — task 4.1 builds the reusable foundation, tasks 5.1 and 6.1 wire it from their respective contexts
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["2.1", "2.2", "2.3"] },
|
||||
{ "id": 2, "tasks": ["2.4", "2.5", "2.6"] },
|
||||
{ "id": 3, "tasks": ["4.1"] },
|
||||
{ "id": 4, "tasks": ["5.1", "6.1"] },
|
||||
{ "id": 5, "tasks": ["5.2", "7.1", "7.2", "7.3"] },
|
||||
{ "id": 6, "tasks": ["7.4"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/forecast-burndown-chart/.config.kiro
Normal file
1
.kiro/specs/forecast-burndown-chart/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "13977c27-d996-4859-a09b-f0162bb13be0", "workflowType": "requirements-first", "specType": "feature"}
|
||||
328
.kiro/specs/forecast-burndown-chart/design.md
Normal file
328
.kiro/specs/forecast-burndown-chart/design.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Design Document: Forecast Burndown Chart
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds a per-metric forecast burndown chart to the CCP Metrics page. It combines historical compliance data (from `compliance_snapshots`) with forward-looking remediation projections (derived from `compliance_items` resolution dates) into a single stacked bar + line chart. A metric selector allows users to switch between individual compliance metrics (e.g., 2.3.5, 2.3.6, 5.2.5).
|
||||
|
||||
The design separates concerns into:
|
||||
1. A **pure helper function** (`computeMetricForecastBurndown`) for testable forecast computation
|
||||
2. Two **API endpoints** (forecast-burndown data + metrics-list) added to the existing VCL multi-vertical router
|
||||
3. A **React chart component** using recharts `ComposedChart` with `Bar` + `Line` + `ReferenceLine`
|
||||
|
||||
Key design decisions:
|
||||
- **No caching**: The API queries `compliance_items` directly on each request so edits on the AEO Compliance page are immediately reflected.
|
||||
- **Pure computation**: The forecast logic is a stateless helper function that accepts pre-fetched data, making it testable in isolation without database mocks.
|
||||
- **Existing patterns**: Routes use the `createVCLMultiVerticalRouter` factory pattern; the chart component follows the same inline-style + recharts patterns as `AggregatedBurndownChart` and `TrendChart`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Frontend
|
||||
MS[MetricSelector] -->|selected metric_id| FBC[ForecastBurndownChart]
|
||||
FBC -->|fetch| API1[GET /metric/:metricId/forecast-burndown]
|
||||
MS -->|fetch on mount| API2[GET /metrics-list]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
API1 --> RH1[Route Handler]
|
||||
API2 --> RH2[Route Handler]
|
||||
RH1 -->|query| DB[(PostgreSQL)]
|
||||
RH1 -->|compute| HF[computeMetricForecastBurndown]
|
||||
RH2 -->|query| DB
|
||||
end
|
||||
|
||||
subgraph Database
|
||||
DB --> CI[compliance_items]
|
||||
DB --> CS[compliance_snapshots]
|
||||
end
|
||||
```
|
||||
|
||||
The data flow:
|
||||
1. On page load, `MetricSelector` fetches `/api/compliance/vcl-multi/metrics-list` to populate the dropdown.
|
||||
2. When a metric is selected, `ForecastBurndownChart` fetches `/api/compliance/vcl-multi/metric/:metricId/forecast-burndown`.
|
||||
3. The route handler queries `compliance_items` for active devices and `compliance_snapshots` for historical data, then passes both to `computeMetricForecastBurndown`.
|
||||
4. The helper returns structured data that the frontend renders as a `ComposedChart`.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: Helper Function
|
||||
|
||||
**File:** `backend/helpers/vclHelpers.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Computes per-metric forecast burndown from device records and historical snapshots.
|
||||
*
|
||||
* @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
|
||||
* Active non-compliant devices for the metric
|
||||
* @param {number} totalAssets
|
||||
* Total device count in scope for this metric (from snapshot or summary)
|
||||
* @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
|
||||
* Pre-computed historical data points (up to 4 months)
|
||||
* @returns {{
|
||||
* historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
|
||||
* }}
|
||||
*/
|
||||
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) { ... }
|
||||
```
|
||||
|
||||
### Backend: API Endpoints
|
||||
|
||||
**File:** `backend/routes/vclMultiVertical.js` (added to existing router)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/compliance/vcl-multi/metric/:metricId/forecast-burndown` | `requireAuth()` | Returns combined historical + forecast data for a metric |
|
||||
| GET | `/api/compliance/vcl-multi/metrics-list` | `requireAuth()` | Returns list of metrics with active non-compliant device counts |
|
||||
|
||||
**Forecast Burndown Response Shape:**
|
||||
```json
|
||||
{
|
||||
"metric_id": "2.3.5",
|
||||
"historical": [
|
||||
{ "month": "2025-01", "total_assets": 500, "non_compliant": 45, "compliance_pct": 91.0 },
|
||||
{ "month": "2025-02", "total_assets": 510, "non_compliant": 38, "compliance_pct": 92.5 }
|
||||
],
|
||||
"forecast": [
|
||||
{ "month": "2025-04", "total_assets": 520, "non_compliant": 25, "compliance_pct": 95.2 },
|
||||
{ "month": "2025-05", "total_assets": 520, "non_compliant": 15, "compliance_pct": 97.1 }
|
||||
],
|
||||
"current_snapshot": {
|
||||
"total_assets": 520,
|
||||
"non_compliant": 30,
|
||||
"compliant": 490,
|
||||
"compliance_pct": 94.2,
|
||||
"blockers": 8,
|
||||
"with_dates": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Metrics List Response Shape:**
|
||||
```json
|
||||
[
|
||||
{ "metric_id": "2.3.5", "device_count": 30 },
|
||||
{ "metric_id": "2.3.6", "device_count": 12 },
|
||||
{ "metric_id": "5.2.5", "device_count": 45 }
|
||||
]
|
||||
```
|
||||
|
||||
### Frontend: Components
|
||||
|
||||
**File:** `frontend/src/components/pages/CCPMetricsPage.js` (added to existing file)
|
||||
|
||||
1. **`MetricSelector`** — Dropdown component that fetches and displays available metrics. Triggers forecast data fetch on selection change.
|
||||
|
||||
2. **`ForecastBurndownChart`** — Renders a `ComposedChart` with:
|
||||
- Blue `Bar` for `total_assets` (left Y-axis)
|
||||
- Orange `Bar` for `non_compliant` (left Y-axis, stacked appearance)
|
||||
- Green `Line` for `compliance_pct` (right Y-axis, 0–100%)
|
||||
- `ReferenceLine` as vertical divider between historical and forecast sections
|
||||
- Forecast data points rendered at 50% opacity
|
||||
|
||||
## Data Models
|
||||
|
||||
### Database Tables (existing, no schema changes required)
|
||||
|
||||
**`compliance_items`** (relevant columns):
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| hostname | TEXT | Device hostname |
|
||||
| metric_id | TEXT | Compliance metric identifier (e.g., "2.3.5") |
|
||||
| status | TEXT | `'active'` or `'resolved'` |
|
||||
| resolution_date | DATE | Planned remediation date (nullable) |
|
||||
| vertical | TEXT | Organizational vertical (e.g., "NTS_AEO") |
|
||||
| team | TEXT | Team assignment |
|
||||
|
||||
**`compliance_snapshots`** (relevant columns):
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| snapshot_month | TEXT | Month in YYYY-MM format |
|
||||
| vertical | TEXT | Organizational vertical |
|
||||
| total_devices | INTEGER | Total devices in scope |
|
||||
| compliant | INTEGER | Compliant device count |
|
||||
| non_compliant | INTEGER | Non-compliant device count |
|
||||
| compliance_pct | NUMERIC(5,2) | Compliance percentage |
|
||||
|
||||
### Internal Data Structures
|
||||
|
||||
**Chart data point (shared between historical and forecast):**
|
||||
```javascript
|
||||
{
|
||||
month: "2025-04", // YYYY-MM
|
||||
total_assets: 520, // Total devices in scope
|
||||
non_compliant: 25, // Non-compliant count
|
||||
compliance_pct: 95.2, // Percentage (0-100, 1 decimal)
|
||||
isForecast: true // Frontend flag for opacity styling
|
||||
}
|
||||
```
|
||||
|
||||
**Current snapshot:**
|
||||
```javascript
|
||||
{
|
||||
total_assets: 520,
|
||||
non_compliant: 30,
|
||||
compliant: 490,
|
||||
compliance_pct: 94.2,
|
||||
blockers: 8, // Devices with no resolution_date
|
||||
with_dates: 22 // Devices with a resolution_date
|
||||
}
|
||||
```
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**Metrics list query:**
|
||||
```sql
|
||||
SELECT metric_id, COUNT(DISTINCT hostname) AS device_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active' AND vertical IS NOT NULL
|
||||
GROUP BY metric_id
|
||||
ORDER BY metric_id ASC
|
||||
```
|
||||
|
||||
**Active devices for a metric:**
|
||||
```sql
|
||||
SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL
|
||||
```
|
||||
|
||||
**Historical snapshots for a vertical (3 months prior):**
|
||||
```sql
|
||||
SELECT snapshot_month AS month, total_devices AS total_assets,
|
||||
non_compliant, compliance_pct::numeric AS compliance_pct
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical = $1 AND snapshot_month >= $2 AND snapshot_month < $3
|
||||
ORDER BY snapshot_month ASC
|
||||
```
|
||||
|
||||
|
||||
## 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 `computeMetricForecastBurndown` helper function is a pure function with clear input/output behavior and universal properties that hold across a wide input space. This makes it an ideal candidate for property-based testing.
|
||||
|
||||
### Property 1: Forecast structure invariant
|
||||
|
||||
*For any* valid `currentDevices` array and non-negative `totalAssets` integer, the `computeMetricForecastBurndown` function SHALL return an object with `historical`, `forecast`, and `current_snapshot` fields, where every element in the `forecast` array contains `month` (YYYY-MM string), `total_assets` (equal to the input `totalAssets` or the current snapshot's total), `non_compliant` (non-negative integer), and `compliance_pct` (number between 0 and 100), and all `total_assets` values in the forecast array are identical.
|
||||
|
||||
**Validates: Requirements 1.4, 3.1, 3.6**
|
||||
|
||||
### Property 2: Blocker and with_dates partition invariant
|
||||
|
||||
*For any* valid `currentDevices` array and non-negative `totalAssets` integer, the `computeMetricForecastBurndown` function SHALL produce a `current_snapshot` where `blockers + with_dates = non_compliant`.
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 3: Compliance percentage formula correctness
|
||||
|
||||
*For any* valid inputs where `totalAssets > 0`, the `computeMetricForecastBurndown` function SHALL compute `compliance_pct` for each forecast and current_snapshot data point as `ROUND((total_assets - non_compliant) / total_assets * 100, 1)`. When `totalAssets` is 0, all `compliance_pct` values SHALL be 0.
|
||||
|
||||
**Validates: Requirements 3.3, 3.10**
|
||||
|
||||
### Property 4: Forecast non_compliant monotonicity
|
||||
|
||||
*For any* valid `currentDevices` array and non-negative `totalAssets` integer, the `forecast` array produced by `computeMetricForecastBurndown` SHALL have monotonically non-increasing `non_compliant` values (each month's `non_compliant` is less than or equal to the previous month's `non_compliant`).
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
### Property 5: Per-month non_compliant computation correctness
|
||||
|
||||
*For any* valid `currentDevices` array and non-negative `totalAssets` integer, for each month in the `forecast` array, the `non_compliant` value SHALL equal the count of devices whose `resolution_date` is after that month (not yet remediated) plus the count of devices with no `resolution_date` (blockers).
|
||||
|
||||
**Validates: Requirements 3.8**
|
||||
|
||||
### Property 6: Forecast horizon bound
|
||||
|
||||
*For any* valid `currentDevices` array and non-negative `totalAssets` integer, the `forecast` array produced by `computeMetricForecastBurndown` SHALL contain at most 12 elements, and SHALL terminate either when all devices with resolution dates are projected to be remediated or at the 12-month maximum, whichever comes first.
|
||||
|
||||
**Validates: Requirements 1.9, 3.9**
|
||||
|
||||
### Property 7: Past-due resolution dates treated as current month
|
||||
|
||||
*For any* device in `currentDevices` whose `resolution_date` is in a month that has already passed relative to the current date, the `computeMetricForecastBurndown` function SHALL treat that device as projected to be remediated in the current month (i.e., it is excluded from `non_compliant` in the first forecast month if that month is after the current month, but remains in `non_compliant` for the current month's snapshot).
|
||||
|
||||
**Validates: Requirements 6.5**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend API Errors
|
||||
|
||||
| Scenario | HTTP Status | Response Body | Behavior |
|
||||
|----------|-------------|---------------|----------|
|
||||
| Unauthenticated request | 401 | `{ "error": "Unauthorized" }` | `requireAuth()` middleware rejects |
|
||||
| Invalid/unknown metricId | 200 | `{ metric_id, historical: [], forecast: [], current_snapshot: {zeros} }` | Graceful empty response |
|
||||
| Database query failure | 500 | `{ "error": "Failed to compute forecast burndown" }` | Catch block logs error, returns 500 |
|
||||
| Database failure on metrics-list | 500 | `{ "error": "Failed to fetch metrics list" }` | Catch block logs error, returns 500 |
|
||||
|
||||
### Frontend Error States
|
||||
|
||||
| Scenario | UI Behavior |
|
||||
|----------|-------------|
|
||||
| Metrics list fetch fails | Inline error with AlertCircle icon, red border, error message text |
|
||||
| Forecast data fetch fails | Inline error in chart area with AlertCircle icon and error description |
|
||||
| Empty metrics list | "No metrics with active non-compliant devices" message |
|
||||
| Empty forecast + empty historical | "No data available for this metric" message in chart area |
|
||||
| Race condition (rapid metric switching) | Discard stale responses; only render data from the most recent selection |
|
||||
|
||||
### Helper Function Edge Cases
|
||||
|
||||
| Input Condition | Behavior |
|
||||
|-----------------|----------|
|
||||
| `currentDevices` is empty | Return empty `forecast`, zeroed `current_snapshot` (except `total_assets`) |
|
||||
| `totalAssets` is 0 | All `compliance_pct` values are 0 (avoid division by zero) |
|
||||
| `totalAssets` < `currentDevices.length` | Use `currentDevices.length` as `non_compliant` without clamping |
|
||||
| All devices are blockers (no resolution dates) | Return empty `forecast` array (no month-by-month projection possible) |
|
||||
| All resolution dates are in the past | Treat as remediated in current month; forecast shows only blockers remaining |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests (via fast-check)
|
||||
|
||||
The `computeMetricForecastBurndown` helper function is a pure function with well-defined invariants, making it ideal for property-based testing. Each property test runs a minimum of 100 iterations with randomly generated inputs.
|
||||
|
||||
**Library:** `fast-check` (already available in the project's test dependencies based on existing `.property.test.js` files)
|
||||
|
||||
**Test file:** `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
**Generator strategy:**
|
||||
- `currentDevices`: Array of `{ hostname: arbitraryString, resolution_date: oneOf(null, arbitraryFutureDate, arbitraryPastDate) }`
|
||||
- `totalAssets`: Non-negative integer (including 0 and values less than device count)
|
||||
- `historicalSnapshots`: Array of 0–4 elements with valid month strings and non-negative counts
|
||||
|
||||
Each test is tagged with: `Feature: forecast-burndown-chart, Property {N}: {title}`
|
||||
|
||||
Properties to implement:
|
||||
1. Forecast structure invariant
|
||||
2. Blocker/with_dates partition invariant
|
||||
3. Compliance percentage formula correctness
|
||||
4. Forecast non_compliant monotonicity
|
||||
5. Per-month non_compliant computation correctness
|
||||
6. Forecast horizon bound
|
||||
7. Past-due resolution dates treated as current month
|
||||
|
||||
### Unit Tests (example-based)
|
||||
|
||||
**Test file:** `backend/__tests__/forecast-burndown-chart.test.js`
|
||||
|
||||
- Specific examples with known inputs and expected outputs
|
||||
- Edge cases: empty devices, zero totalAssets, all blockers, all past-due dates
|
||||
- Integration with route handlers (mocked database)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Verify API endpoints return correct response shapes with seeded database data
|
||||
- Verify authentication middleware is applied
|
||||
- Verify correct SQL filtering (vertical scoping, active-only devices)
|
||||
- Verify historical data derivation from `compliance_snapshots`
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
- Component rendering with mock data (snapshot tests)
|
||||
- Loading and error states
|
||||
- Metric selector interaction (selection triggers fetch)
|
||||
- Race condition handling (rapid metric switching discards stale responses)
|
||||
132
.kiro/specs/forecast-burndown-chart/requirements.md
Normal file
132
.kiro/specs/forecast-burndown-chart/requirements.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature adds a per-metric forecast burndown chart to the CCP Metrics page. Unlike the existing aggregated burndown (which rolls up across all verticals by hostname), this chart focuses on individual compliance metrics (e.g., 2.3.5, 2.3.6, 5.2.5) and combines historical compliance data with forward-looking remediation projections in a single stacked bar + line chart. The chart displays actual historical data on the left side and forecasted future data on the right side, separated by a bold vertical divider. A metric selector allows users to switch between metrics. The chart reads from the same `compliance_items` table that the AEO Compliance page writes to, so device-level edits (resolution_date changes) dynamically feed into the forecast.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Forecast_Burndown_API**: The backend endpoint that returns combined historical and forecast burndown data for a specific compliance metric, identified by `metric_id`.
|
||||
- **Metric_Selector**: A dropdown or picker UI component that allows the user to choose which compliance metric to display in the chart.
|
||||
- **Historical_Data**: Monthly compliance snapshots from the past 3 months plus the current month, showing actual total asset counts, non-compliant device counts, and compliance percentages as recorded at each snapshot point.
|
||||
- **Forecast_Data**: Projected future monthly data points computed from active non-compliant devices with scheduled `resolution_date` values, assuming total asset count remains constant and all remediation plans complete on schedule.
|
||||
- **Compliance_Percentage**: The ratio of compliant devices to total devices for a given metric, expressed as a percentage (0%–100%).
|
||||
- **Total_Assets**: The total number of devices in scope for a given metric at a point in time.
|
||||
- **Non_Compliant_Count**: The number of devices that are non-compliant for a given metric at a point in time.
|
||||
- **Divider_Line**: A bold vertical line on the chart separating actual historical data (left) from forecasted future data (right).
|
||||
- **CCP_Metrics_Page**: The page component (`CCPMetricsPage.js`) where the forecast burndown chart is displayed.
|
||||
- **Compliance_Items_Table**: The `compliance_items` PostgreSQL table that tracks non-compliant devices with fields including `hostname`, `metric_id`, `status`, `resolution_date`, `vertical`, and `team`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Per-Metric Forecast Burndown API Endpoint
|
||||
|
||||
**User Story:** As a compliance analyst, I want an API endpoint that returns combined historical and forecast burndown data for a specific metric, so that the frontend can render a chart showing past compliance trends and projected future remediation.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/compliance/vcl-multi/metric/:metricId/forecast-burndown`, THE Forecast_Burndown_API SHALL return a JSON response containing `metric_id`, `historical`, `forecast`, and `current_snapshot` fields within 3 seconds.
|
||||
2. THE Forecast_Burndown_API SHALL compute `historical` as an array of exactly 4 monthly data points (the current month plus the 3 preceding months in chronological order), where each data point contains `month` (YYYY-MM), `total_assets`, `non_compliant`, and `compliance_pct`.
|
||||
3. THE Forecast_Burndown_API SHALL derive historical `total_assets` and `non_compliant` counts from the `compliance_snapshots` table filtered by the vertical associated with the requested `metricId` in `compliance_items` (determined by querying the `vertical` column of active devices matching that `metric_id`), combined with per-metric device counts from `compliance_items` for that snapshot period.
|
||||
4. THE Forecast_Burndown_API SHALL compute `forecast` as an array of projected future monthly data points, where each data point contains `month` (YYYY-MM), `total_assets`, `non_compliant`, and `compliance_pct`.
|
||||
5. THE Forecast_Burndown_API SHALL project forecast data by assuming the current `total_assets` count remains constant and that each non-compliant device with a `resolution_date` in a future month will become compliant in that month.
|
||||
6. THE Forecast_Burndown_API SHALL compute `current_snapshot` containing `total_assets`, `non_compliant`, `compliant`, `compliance_pct`, `blockers` (count of devices with no resolution_date), and `with_dates` (count of devices with a resolution_date).
|
||||
7. THE Forecast_Burndown_API SHALL require authentication via `requireAuth()` middleware.
|
||||
8. IF the `metricId` parameter does not match any active devices in `compliance_items` (where `status = 'active'` and `vertical IS NOT NULL`), THEN THE Forecast_Burndown_API SHALL return a 200 response with empty `historical` and `forecast` arrays and `current_snapshot` with all numeric fields set to 0.
|
||||
9. THE Forecast_Burndown_API SHALL return forecast data extending forward until all devices with resolution dates are projected to be remediated, or for a maximum of 12 months from the current date, whichever comes first.
|
||||
10. IF the database query fails or an internal error occurs while processing the request, THEN THE Forecast_Burndown_API SHALL return a 500 response with a JSON body containing an `error` field indicating the failure reason.
|
||||
|
||||
### Requirement 2: Available Metrics List Endpoint
|
||||
|
||||
**User Story:** As a frontend developer, I want an API endpoint that returns the list of distinct metrics with active non-compliant devices, so that the metric selector can be populated with valid options.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/compliance/vcl-multi/metrics-list`, THE Forecast_Burndown_API SHALL return a 200 response containing a JSON array of objects, each containing `metric_id` (string) and `device_count` (integer representing the number of distinct hostnames with status `active` for that metric) fields.
|
||||
2. THE Forecast_Burndown_API SHALL include only metrics that have at least one active non-compliant device in `compliance_items` where `vertical IS NOT NULL`.
|
||||
3. THE Forecast_Burndown_API SHALL sort the returned array by `metric_id` in ascending alphanumeric order.
|
||||
4. THE Forecast_Burndown_API SHALL require authentication via `requireAuth()` middleware.
|
||||
5. IF no metrics have active non-compliant devices with a non-null vertical, THEN THE Forecast_Burndown_API SHALL return a 200 response containing an empty JSON array.
|
||||
6. IF the database query fails, THEN THE Forecast_Burndown_API SHALL return a 500 response with a JSON object containing an `error` field.
|
||||
|
||||
### Requirement 3: Forecast Computation Logic
|
||||
|
||||
**User Story:** As a developer, I want a pure helper function that computes forecast burndown data for a given metric from device records and historical snapshots, so that the computation is testable in isolation.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE `computeMetricForecastBurndown` helper function SHALL accept a `currentDevices` array (each element containing at minimum `hostname` and `resolution_date` fields, where `resolution_date` is either a YYYY-MM-DD string or null), a `totalAssets` count (non-negative integer), and a `historicalSnapshots` array (each element containing `month` as YYYY-MM, `total_assets`, `non_compliant`, and `compliance_pct`), and return an object with `historical`, `forecast`, and `current_snapshot` fields.
|
||||
2. FOR ALL valid inputs, THE `computeMetricForecastBurndown` function SHALL satisfy the invariant: `current_snapshot.blockers + current_snapshot.with_dates = current_snapshot.non_compliant`.
|
||||
3. FOR ALL valid inputs where `total_assets > 0`, THE `computeMetricForecastBurndown` function SHALL compute `compliance_pct` as `ROUND((total_assets - non_compliant) / total_assets * 100, 1)` for each data point.
|
||||
4. FOR ALL forecast data points, THE `computeMetricForecastBurndown` function SHALL produce monotonically non-increasing `non_compliant` counts (each month has equal or fewer non-compliant devices than the previous month).
|
||||
5. FOR ALL forecast data points, THE `computeMetricForecastBurndown` function SHALL produce monotonically non-decreasing `compliance_pct` values.
|
||||
6. THE `computeMetricForecastBurndown` function SHALL hold `total_assets` constant across all forecast data points, using the current month's total as the baseline.
|
||||
7. WHEN the `currentDevices` array is empty, THE `computeMetricForecastBurndown` function SHALL return an empty `forecast` array and `current_snapshot` with all zero values except `total_assets`.
|
||||
8. FOR ALL forecast months, THE `computeMetricForecastBurndown` function SHALL compute `non_compliant` as the count of devices whose `resolution_date` is after that month (devices not yet remediated) plus `blockers` (devices with no resolution_date).
|
||||
9. THE `computeMetricForecastBurndown` function SHALL generate forecast data points extending forward month-by-month until all devices with resolution dates are projected to be remediated, or for a maximum of 12 months from the current date, whichever comes first.
|
||||
10. IF `totalAssets` is 0, THEN THE `computeMetricForecastBurndown` function SHALL return `compliance_pct` of 0 for all data points in `historical`, `forecast`, and `current_snapshot`.
|
||||
11. IF `totalAssets` is less than the number of elements in `currentDevices`, THEN THE `computeMetricForecastBurndown` function SHALL use the count of `currentDevices` as `non_compliant` without clamping to `totalAssets`.
|
||||
|
||||
### Requirement 4: Forecast Burndown Chart Component
|
||||
|
||||
**User Story:** As a senior leader viewing the CCP Metrics page, I want to see a combined historical and forecast burndown chart for each metric, so that I can visualize past compliance trends and projected future remediation timelines.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Forecast_Burndown_Chart SHALL be displayed on the CCP_Metrics_Page in a dedicated section below the Metric_Selector.
|
||||
2. THE Forecast_Burndown_Chart SHALL render a stacked bar chart where blue bars represent Total_Assets (100% height baseline) and orange bars represent Non_Compliant_Count.
|
||||
3. THE Forecast_Burndown_Chart SHALL render a green line overlay showing Compliance_Percentage as a trend line with percentage labels at each data point.
|
||||
4. THE Forecast_Burndown_Chart SHALL display a left Y-axis scaled to the maximum Total_Assets value for device counts, and a right Y-axis scaled from 0% to 100% for Compliance_Percentage.
|
||||
5. THE Forecast_Burndown_Chart SHALL display the X-axis labeled with months (YYYY-MM format), showing up to 16 data points (up to 4 historical months plus up to 12 forecast months).
|
||||
6. THE Forecast_Burndown_Chart SHALL render a bold vertical Divider_Line separating Historical_Data (left side) from Forecast_Data (right side), positioned between the last historical data point and the first forecast data point.
|
||||
7. THE Forecast_Burndown_Chart SHALL render forecast bars and line segments with 50% opacity to visually distinguish projections from actuals.
|
||||
8. THE Forecast_Burndown_Chart SHALL display raw device count labels inside the bars (total assets count in blue bars, non-compliant count in orange bars).
|
||||
9. THE Forecast_Burndown_Chart SHALL display compliance percentage values as labels on the green trend line at each data point.
|
||||
10. WHEN the Forecast_Burndown_API returns both empty `historical` and empty `forecast` arrays for a selected metric, THE Forecast_Burndown_Chart SHALL display a message indicating no data is available for that metric.
|
||||
11. WHEN the user selects a different metric from the Metric_Selector, THE Forecast_Burndown_Chart SHALL fetch updated data from the Forecast_Burndown_API and re-render the chart with the new metric's data.
|
||||
12. WHILE the Forecast_Burndown_API request is in flight, THE Forecast_Burndown_Chart SHALL display a loading indicator in place of the chart content.
|
||||
13. IF the Forecast_Burndown_API request fails, THEN THE Forecast_Burndown_Chart SHALL display an inline error message with an AlertCircle icon and the error description, styled with a red border consistent with the existing error display pattern on the CCP_Metrics_Page.
|
||||
14. WHEN the Forecast_Burndown_API returns a non-empty `historical` array but an empty `forecast` array, THE Forecast_Burndown_Chart SHALL render only the historical bars and trend line without a Divider_Line or forecast section.
|
||||
|
||||
### Requirement 5: Metric Selector Component
|
||||
|
||||
**User Story:** As a compliance analyst, I want a metric picker that lets me choose which metric's forecast burndown to view, so that I can analyze remediation progress for individual compliance requirements.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Metric_Selector SHALL be displayed above or adjacent to the Forecast_Burndown_Chart on the CCP_Metrics_Page.
|
||||
2. THE Metric_Selector SHALL populate its options by fetching the available metrics list from the metrics-list endpoint on page load.
|
||||
3. THE Metric_Selector SHALL display each metric option showing the `metric_id` and the count of active non-compliant devices.
|
||||
4. WHEN the user selects a metric, THE Metric_Selector SHALL trigger a data fetch for that metric's forecast burndown and update the chart.
|
||||
5. WHEN the Metric_Selector finishes loading the metrics list and defaults to the first metric in the sorted list, THE Metric_Selector SHALL automatically trigger a data fetch for that default metric's forecast burndown and update the chart.
|
||||
6. WHILE the metrics list is loading, THE Metric_Selector SHALL display a loading indicator and remain non-interactive until the fetch completes or fails.
|
||||
7. IF the metrics list endpoint returns an empty array, THEN THE Metric_Selector SHALL display a message indicating no metrics with active non-compliant devices exist.
|
||||
8. IF the metrics list endpoint request fails, THEN THE Metric_Selector SHALL display an inline error message consistent with the existing error display pattern on the CCP_Metrics_Page.
|
||||
9. WHILE the forecast burndown data is loading after a metric selection, THE Metric_Selector SHALL remain interactive, and if the user selects a different metric before the previous fetch completes, THE Metric_Selector SHALL discard the previous in-flight response and use only the result from the most recent selection.
|
||||
|
||||
### Requirement 6: Dynamic Data Integration with AEO Compliance Page
|
||||
|
||||
**User Story:** As a compliance analyst, I want the forecast burndown chart to reflect device-level edits made on the AEO Compliance page in real time, so that when I update a device's resolution_date for a metric, the forecast projection updates accordingly on the next chart load.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Forecast_Burndown_API SHALL query the `compliance_items` table directly (not a cached copy), so that edits made via the AEO Compliance page's `PATCH /items/:hostname/metadata` endpoint are reflected in the next API call made after the PATCH response completes.
|
||||
2. WHEN a user changes a device's `resolution_date` on the AEO Compliance page for a specific metric, THE Forecast_Burndown_API SHALL include that updated date in its forecast computation on the next request for that metric.
|
||||
3. WHEN a device's status changes from `active` to `resolved` on the AEO Compliance page, THE Forecast_Burndown_API SHALL exclude that device from the non-compliant count on the next request.
|
||||
4. IF a device's `resolution_date` is cleared (set to NULL) on the AEO Compliance page, THEN THE Forecast_Burndown_API SHALL count that device as a blocker in the `current_snapshot.blockers` count and exclude it from month-by-month forecast remediation projections on the next request.
|
||||
5. IF a device's `resolution_date` is changed to a date in a month that has already passed relative to the current date, THEN THE Forecast_Burndown_API SHALL treat that device as projected to be remediated in the current month (not exclude it from non-compliant count until its status changes to `resolved`).
|
||||
6. THE Forecast_Burndown_API SHALL use the current state of `compliance_items` as the source of truth for the current snapshot and forecast projections, with no application-level caching between requests.
|
||||
|
||||
### Requirement 7: Historical Data Derivation
|
||||
|
||||
**User Story:** As a compliance analyst, I want the chart to show actual historical compliance data for the past 3 months, so that I can see the real trend leading into the forecast.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Forecast_Burndown_API SHALL derive historical data from the `compliance_snapshots` table, which records monthly total_devices, compliant, and non_compliant counts per vertical, for the 3 calendar months immediately preceding the current month.
|
||||
2. THE Forecast_Burndown_API SHALL compute per-metric historical non_compliant count at each snapshot point by multiplying the vertical's snapshot non_compliant count by the ratio of the metric's non-compliant devices to the vertical's total non-compliant devices (as recorded in `compliance_items` for that period), rounding to the nearest integer.
|
||||
3. IF the vertical's total non_compliant count is 0 at a historical snapshot point, THEN THE Forecast_Burndown_API SHALL set the metric's non_compliant count to 0 for that data point.
|
||||
4. THE Forecast_Burndown_API SHALL use the vertical's snapshot total_devices as the metric's total_assets for each historical data point.
|
||||
5. WHEN no historical snapshots exist for a metric's vertical, THE Forecast_Burndown_API SHALL return an empty `historical` array.
|
||||
6. THE Forecast_Burndown_API SHALL include the current month as the most recent historical data point, computed from live `compliance_items` data rather than a stored snapshot.
|
||||
7. THE Forecast_Burndown_API SHALL return historical data points in chronological order (oldest first).
|
||||
158
.kiro/specs/forecast-burndown-chart/tasks.md
Normal file
158
.kiro/specs/forecast-burndown-chart/tasks.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Implementation Plan: Forecast Burndown Chart
|
||||
|
||||
## Overview
|
||||
|
||||
Add a per-metric forecast burndown chart to the CCP Metrics page. A pure helper function (`computeMetricForecastBurndown`) computes forecast projections from device records and historical snapshots. Two new API endpoints serve the metrics list and forecast data. A React frontend renders a metric selector and a ComposedChart (Bar + Line + ReferenceLine) using recharts. No database migrations are needed — the feature reads from existing `compliance_items` and `compliance_snapshots` tables.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Implement the computeMetricForecastBurndown helper function
|
||||
- [x] 1.1 Add computeMetricForecastBurndown to backend/helpers/vclHelpers.js
|
||||
- Implement the pure function accepting `currentDevices`, `totalAssets`, and `historicalSnapshots`
|
||||
- Return object with `historical`, `forecast`, and `current_snapshot` fields
|
||||
- Compute `current_snapshot.blockers` (devices with no resolution_date) and `current_snapshot.with_dates` (devices with a resolution_date)
|
||||
- Compute `compliance_pct` as `ROUND((total_assets - non_compliant) / total_assets * 100, 1)`, returning 0 when totalAssets is 0
|
||||
- Generate forecast months by iterating forward from current month, decrementing non_compliant as devices reach their resolution_date month
|
||||
- Treat past-due resolution dates as remediated in the current month
|
||||
- Hold total_assets constant across all forecast data points
|
||||
- Terminate forecast when all dated devices are remediated or at 12-month maximum
|
||||
- Return empty forecast array when all devices are blockers (no resolution dates)
|
||||
- Export the function for use in route handlers and tests
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 6.5_
|
||||
|
||||
- [ ]* 1.2 Write property test: Forecast structure invariant
|
||||
- **Property 1: Forecast structure invariant**
|
||||
- **Validates: Requirements 1.4, 3.1, 3.6**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.3 Write property test: Blocker and with_dates partition invariant
|
||||
- **Property 2: Blocker and with_dates partition invariant**
|
||||
- **Validates: Requirements 3.2**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.4 Write property test: Compliance percentage formula correctness
|
||||
- **Property 3: Compliance percentage formula correctness**
|
||||
- **Validates: Requirements 3.3, 3.10**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.5 Write property test: Forecast non_compliant monotonicity
|
||||
- **Property 4: Forecast non_compliant monotonicity**
|
||||
- **Validates: Requirements 3.4**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.6 Write property test: Per-month non_compliant computation correctness
|
||||
- **Property 5: Per-month non_compliant computation correctness**
|
||||
- **Validates: Requirements 3.8**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.7 Write property test: Forecast horizon bound
|
||||
- **Property 6: Forecast horizon bound**
|
||||
- **Validates: Requirements 1.9, 3.9**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [ ]* 1.8 Write property test: Past-due resolution dates treated as current month
|
||||
- **Property 7: Past-due resolution dates treated as current month**
|
||||
- **Validates: Requirements 6.5**
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.property.test.js`
|
||||
|
||||
- [x] 2. Checkpoint - Helper function verified
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Implement backend API endpoints
|
||||
- [x] 3.1 Add GET /metrics-list endpoint to backend/routes/vclMultiVertical.js
|
||||
- Add route handler at `/metrics-list` with `requireAuth()` middleware
|
||||
- Query `compliance_items` for distinct metric_ids with active non-compliant devices where vertical IS NOT NULL
|
||||
- Return JSON array of `{ metric_id, device_count }` sorted by metric_id ascending
|
||||
- Return empty array when no metrics have active devices
|
||||
- Return HTTP 500 with `{ "error": "Failed to fetch metrics list" }` on database failure
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 3.2 Add GET /metric/:metricId/forecast-burndown endpoint to backend/routes/vclMultiVertical.js
|
||||
- Add route handler with `requireAuth()` middleware
|
||||
- Query active devices for the metric from `compliance_items` (status = 'active', vertical IS NOT NULL)
|
||||
- Determine the vertical from active devices and query `compliance_snapshots` for 3 months of historical data
|
||||
- Compute per-metric historical non_compliant using the ratio method from Requirement 7.2
|
||||
- Include current month as the most recent historical data point computed from live data
|
||||
- Pass data to `computeMetricForecastBurndown` helper
|
||||
- Return response with `metric_id`, `historical`, `forecast`, and `current_snapshot`
|
||||
- Return 200 with empty arrays and zeroed snapshot when metricId has no active devices
|
||||
- Return HTTP 500 with `{ "error": "Failed to compute forecast burndown" }` on database failure
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
|
||||
|
||||
- [ ]* 3.3 Write unit tests for API endpoints
|
||||
- Test metrics-list returns correct shape with mocked database
|
||||
- Test forecast-burndown returns correct shape with mocked database
|
||||
- Test authentication middleware is applied to both endpoints
|
||||
- Test empty/error states
|
||||
- Test file: `backend/__tests__/forecast-burndown-chart.test.js`
|
||||
- _Requirements: 1.7, 1.8, 1.10, 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 4. Checkpoint - Backend complete
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Implement frontend MetricSelector and ForecastBurndownChart components
|
||||
- [x] 5.1 Add MetricSelector component to frontend/src/components/pages/CCPMetricsPage.js
|
||||
- Fetch metrics list from `/api/compliance/vcl-multi/metrics-list` on mount
|
||||
- Display dropdown showing each metric_id with active non-compliant device count
|
||||
- Auto-select first metric and trigger forecast data fetch on load
|
||||
- Handle loading state (non-interactive while fetching)
|
||||
- Handle empty state ("No metrics with active non-compliant devices")
|
||||
- Handle error state (inline error with AlertCircle icon, red border)
|
||||
- On selection change, trigger `onMetricSelect` callback
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
|
||||
|
||||
- [x] 5.2 Add ForecastBurndownChart component to frontend/src/components/pages/CCPMetricsPage.js
|
||||
- Fetch forecast data from `/api/compliance/vcl-multi/metric/:metricId/forecast-burndown` when metric changes
|
||||
- Render recharts ComposedChart with:
|
||||
- Blue Bar for total_assets (left Y-axis)
|
||||
- Orange Bar for non_compliant (left Y-axis)
|
||||
- Green Line for compliance_pct (right Y-axis, 0-100%)
|
||||
- ReferenceLine as vertical divider between historical and forecast sections
|
||||
- Render forecast data points at 50% opacity
|
||||
- Display raw device count labels inside bars
|
||||
- Display compliance percentage labels on the trend line
|
||||
- X-axis labeled with months (YYYY-MM format)
|
||||
- Left Y-axis scaled to max total_assets, right Y-axis 0-100%
|
||||
- Handle loading state (loading indicator in chart area)
|
||||
- Handle error state (inline error with AlertCircle icon and description)
|
||||
- Handle empty data state ("No data available for this metric")
|
||||
- Handle historical-only state (no divider line when forecast is empty)
|
||||
- Discard stale responses on rapid metric switching (race condition handling)
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 4.10, 4.11, 4.12, 4.13, 4.14, 5.9_
|
||||
|
||||
- [x] 5.3 Wire MetricSelector and ForecastBurndownChart into CCPMetricsPage layout
|
||||
- Add state for selected metric
|
||||
- Place MetricSelector above ForecastBurndownChart in a dedicated section
|
||||
- Connect selection change to chart data fetch
|
||||
- Follow existing inline-style patterns from AggregatedBurndownChart and TrendChart
|
||||
- _Requirements: 4.1, 5.1, 5.4_
|
||||
|
||||
- [x] 6. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- The helper function is pure and stateless — all 7 property tests exercise it in isolation without database mocks
|
||||
- All backend changes are in `backend/helpers/vclHelpers.js` and `backend/routes/vclMultiVertical.js`
|
||||
- All frontend changes are within `frontend/src/components/pages/CCPMetricsPage.js`
|
||||
- Property-based tests use `fast-check` (already in project dependencies)
|
||||
- No database migrations needed — uses existing `compliance_items` and `compliance_snapshots` tables
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8"] },
|
||||
{ "id": 2, "tasks": ["3.1", "3.2"] },
|
||||
{ "id": 3, "tasks": ["3.3"] },
|
||||
{ "id": 4, "tasks": ["5.1", "5.2"] },
|
||||
{ "id": 5, "tasks": ["5.3"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/granite-loader-export/.config.kiro
Normal file
1
.kiro/specs/granite-loader-export/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
version = "1.0"
|
||||
319
.kiro/specs/granite-loader-export/design.md
Normal file
319
.kiro/specs/granite-loader-export/design.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Design Document: Granite Loader Sheet Export
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds a Granite Team_Device Loader xlsx generator to the STEAM Security Dashboard. It integrates with the existing Ivanti Queue (for CARD/GRANITE items) and provides a standalone mode for ad-hoc device lists. The system enriches device data from the CARD API, allows bulk defaults with per-row overrides in an editable preview table, and generates a properly formatted xlsx for upload to SNIP XperLoad.
|
||||
|
||||
Key design decisions:
|
||||
- **Frontend-driven xlsx generation**: The xlsx is generated client-side using the `xlsx` library (already a project dependency) to avoid backend file I/O and temp file cleanup.
|
||||
- **Backend CARD enrichment endpoint**: A single batch endpoint accepts an array of IPs and returns enriched Granite fields from CARD. This keeps the frontend simple and centralizes CARD API token management.
|
||||
- **Reuse queue data model**: Queue items already have `ip_address`, `hostname`, and `workflow_type` — no schema changes needed.
|
||||
- **Modal-based UI**: The loader configuration lives in a modal (like the Consolidation Modal pattern) to avoid adding a new page while keeping the queue page clean.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Frontend
|
||||
QP[IvantiTodoQueuePage] -->|selected CARD/GRANITE items| LM[LoaderModal]
|
||||
NAV[Nav Drawer / Standalone] -->|manual IP list| LM
|
||||
LM -->|enrich request| API[POST /api/card/enrich-batch]
|
||||
LM -->|generate xlsx| XLSX[xlsx library - client-side]
|
||||
XLSX -->|download| FILE[Loader_Change_TEAM_DATE.xlsx]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
API --> CARD[CARD API Helper]
|
||||
CARD -->|per-IP lookup| EXT[card.charter.com]
|
||||
end
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: Batch Enrichment Endpoint
|
||||
|
||||
**File:** `backend/routes/cardApi.js` (added to existing router)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/card/enrich-batch` | Admin, Standard_User | Batch lookup IPs in CARD, return Granite-relevant fields |
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"ips": ["10.240.78.110", "10.240.78.111", "172.16.5.20"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"ip": "10.240.78.110",
|
||||
"found": true,
|
||||
"equip_inst_id": "1931008",
|
||||
"hostname": "NDCW-SW-CORE-01",
|
||||
"site_name": "ENWDCOCD-PEAKVIEW-SRDC",
|
||||
"mgmt_ip_asn": "11427",
|
||||
"responsible_team": "NTS-AEO-STEAM",
|
||||
"equipment_class": "S",
|
||||
"equip_template": "DISC-CISCO NEXUS 9300",
|
||||
"equip_status": "Active"
|
||||
},
|
||||
{
|
||||
"ip": "172.16.5.20",
|
||||
"found": false,
|
||||
"equip_inst_id": null,
|
||||
"hostname": null,
|
||||
"error": "IP not found in CARD"
|
||||
}
|
||||
],
|
||||
"enriched_count": 1,
|
||||
"not_found_count": 1,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
- Accepts up to 200 IPs per request (matches Requirement 1.4 limit).
|
||||
- For each IP, constructs asset ID candidates with known suffixes (CTEC, NATL, CHTR, etc.) and queries `GET /api/v1/owner/{assetId}`.
|
||||
- Falls back to team asset search if direct owner lookup fails.
|
||||
- Extracts fields from `ncim_discovery`, `netops_granite_allips`, `card_flags`, and `owner` on the asset record.
|
||||
- Returns partial results on CARD API errors (best-effort enrichment).
|
||||
|
||||
### Frontend: LoaderModal Component
|
||||
|
||||
**File:** `frontend/src/components/LoaderModal.js`
|
||||
|
||||
A modal component that handles the full loader sheet workflow:
|
||||
|
||||
**Props:**
|
||||
```javascript
|
||||
{
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
// Pre-populated from queue selection (null in standalone mode)
|
||||
initialDevices: Array<{ ip_address: string, hostname: string }> | null,
|
||||
}
|
||||
```
|
||||
|
||||
**Internal State:**
|
||||
```javascript
|
||||
{
|
||||
operationType: 'Change' | 'Add' | 'Delete' | 'Move',
|
||||
selectedColumns: Set<string>, // checked column IDs
|
||||
devices: Array<DeviceRow>, // the editable row data
|
||||
bulkDefaults: Record<string, string>, // column → default value
|
||||
overrides: Record<string, Record<string, string>>, // rowIndex → column → value
|
||||
enriching: boolean,
|
||||
enrichErrors: Array<{ ip: string, error: string }>,
|
||||
}
|
||||
```
|
||||
|
||||
**DeviceRow shape:**
|
||||
```javascript
|
||||
{
|
||||
ip_address: string,
|
||||
hostname: string,
|
||||
// CARD-enriched fields (populated after enrichment)
|
||||
equip_inst_id: string | null,
|
||||
site_name: string | null,
|
||||
mgmt_ip_asn: string | null,
|
||||
responsible_team: string | null,
|
||||
equipment_class: string | null,
|
||||
equip_template: string | null,
|
||||
equip_status: string | null,
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend: Column Configuration
|
||||
|
||||
**File:** `frontend/src/utils/graniteLoaderConfig.js`
|
||||
|
||||
Pure data module defining the 41 columns, their groupings, and operation-type requirements:
|
||||
|
||||
```javascript
|
||||
export const LOADER_COLUMNS = [
|
||||
{ id: 'DELETE', label: 'DELETE', group: 'Identification', requiredFor: ['Delete'] },
|
||||
{ id: 'SET_CONFIRMED', label: 'SET_CONFIRMED', group: 'Identification', requiredFor: [] },
|
||||
{ id: 'EQUIPMENT_CLASS', label: 'EQUIPMENT CLASS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_INST_ID', label: 'EQUIP_INST_ID', group: 'Identification', requiredFor: ['Change', 'Move', 'Delete'] },
|
||||
{ id: 'SITE_NAME', label: 'SITE_NAME', group: 'Identification', requiredFor: ['Add', 'Move'] },
|
||||
{ id: 'EQUIP_NAME', label: 'EQUIP_NAME', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_TEMPLATE', label: 'EQUIP_TEMPLATE', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_STATUS', label: 'EQUIP_STATUS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'RESPONSIBLE_TEAM', label: 'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM', group: 'Responsible Org', requiredFor: ['Add'] },
|
||||
{ id: 'IPV4_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ADDRESS', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'MAC_ADDRESS', label: 'UDA#IP_ADDRESSING#MAC ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'MGMT_IP_ASN', label: 'UDA#IP_ADDRESSING#MGMT_IP_ASN', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'SERIALNUMBER', label: 'SERIALNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_DISCOVERY', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_REASON', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY REASON', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'IPV6_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV6_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'ILOM_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ILOM_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'APP_ID_ASSET_TAG', label: 'UDA#CHERWELL_CMDB#APP ID ASSET TAG', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'DEVICE_FUNCTION', label: 'UDA#CHERWELL_CMDB#DEVICE_FUNCTION', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'ENVIRONMENT', label: 'UDA#CHERWELL_CMDB#ENVIRONMENT', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'SECONDARY_MGMT_IP', label: 'UDA#IP_ADDRESSING#SECONDARY_MGMT_IP_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'VIP', label: 'UDA#IP_ADDRESSING#VIP', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'FLOATING_IP', label: 'UDA#IP_ADDRESSING#FLOATING IP ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_1', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 1', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_2', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 2', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_3', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 3', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'EQUIP_MODEL', label: 'EQUIP_MODEL', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_COMMENTS', label: 'EQUIP_COMMENTS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_PARTNUMBER', label: 'EQUIP_PARTNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS', label: 'UDA#CONTROLLER CONFIG#OS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS_VERSION', label: 'UDA#CONTROLLER CONFIG#OS VERSION', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'CPU_CORES', label: 'UDA#CONTROLLER CONFIG#TOTAL CPU CORES', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'RAM_GB', label: 'UDA#CONTROLLER CONFIG#RAM IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'STORAGE_GB', label: 'UDA#CONTROLLER CONFIG#STORAGE IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'ARCHER_ID', label: 'UDA#WIFI EQUIP INFO#ARCHER ID', group: 'Other', requiredFor: [] },
|
||||
{ id: 'INSTALL_LOCATION', label: 'UDA#WIFI EQUIP INFO#INSTALL LOCATION', group: 'Other', requiredFor: [] },
|
||||
{ id: 'SYSNAME', label: 'UDA#EQUIPMENT_INFO#SYSNAME', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LATITUDE', label: 'UDA#WIFI EQUIP INFO#LATITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LONGITUDE', label: 'UDA#WIFI EQUIP INFO#LONGITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSTYPE', label: 'UDA#EQUIP MIGRATION#OSTYPE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSVERSION', label: 'UDA#EQUIP MIGRATION#OSVERSION', group: 'Other', requiredFor: [] },
|
||||
];
|
||||
|
||||
export const COLUMN_GROUPS = [
|
||||
'Identification',
|
||||
'IP Addressing',
|
||||
'Responsible Org',
|
||||
'Discovery',
|
||||
'Cyber Metrics',
|
||||
'Equipment Info',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const OPERATION_TYPES = ['Change', 'Add', 'Delete', 'Move'];
|
||||
```
|
||||
|
||||
### Frontend: XLSX Generation
|
||||
|
||||
**File:** `frontend/src/utils/graniteLoaderExport.js`
|
||||
|
||||
Pure function that takes the configured state and produces an xlsx workbook:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @param {Object} config
|
||||
* @param {string} config.operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||||
* @param {Array<string>} config.columnIds - ordered list of selected column IDs
|
||||
* @param {Array<Object>} config.rows - device rows with resolved values (bulk + overrides merged)
|
||||
* @returns {Blob} xlsx file as a Blob for download
|
||||
*/
|
||||
export function generateLoaderXlsx(config) { ... }
|
||||
```
|
||||
|
||||
Uses the `xlsx` library (already in `frontend/package.json`) to create a workbook with a single "Load_Sheet" worksheet.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Queue-Initiated Flow
|
||||
|
||||
1. User selects CARD/GRANITE items on IvantiTodoQueuePage
|
||||
2. User clicks "Generate Loader Sheet" in floating action bar
|
||||
3. LoaderModal opens with `initialDevices` populated from selected items
|
||||
4. User selects Operation Type (defaults to "Change")
|
||||
5. User checks desired columns (required columns pre-checked)
|
||||
6. User optionally clicks "Enrich from CARD" → `POST /api/card/enrich-batch`
|
||||
7. EQUIP_INST_ID and other fields populate in the preview table
|
||||
8. User sets bulk defaults and per-row overrides as needed
|
||||
9. User clicks "Download" → client-side xlsx generation → browser download
|
||||
|
||||
### Standalone Flow
|
||||
|
||||
1. User navigates to standalone access point (nav drawer link or CARD page section)
|
||||
2. LoaderModal opens with empty device list
|
||||
3. User pastes IPs (textarea, one per line or comma-separated) → rows populate
|
||||
4. Steps 4–9 same as above
|
||||
|
||||
### CARD Enrichment Flow (Backend)
|
||||
|
||||
1. Frontend sends `POST /api/card/enrich-batch` with array of IPs
|
||||
2. Backend iterates IPs, for each:
|
||||
a. Try `GET /api/v1/owner/{ip}-CTEC`, then `-NATL`, then `-CHTR` (known suffixes)
|
||||
b. If found, extract fields from the asset record
|
||||
c. If not found via owner lookup, search team assets for the IP
|
||||
3. Return results array with found/not-found status per IP
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| CARD API not configured | "Enrich from CARD" button hidden; tooltip explains why |
|
||||
| CARD API timeout on individual IP | Mark that IP as not-found, continue with others |
|
||||
| CARD API auth failure | Show error toast, abort enrichment, preserve any already-enriched data |
|
||||
| All IPs not found in CARD | Show warning banner "No devices found in CARD — enter EQUIP_INST_ID manually" |
|
||||
| Required field missing on download | Highlight cells, show warning count, allow download with acknowledgment |
|
||||
| xlsx generation failure | Show error toast with message |
|
||||
| More than 200 IPs submitted | Frontend truncates to 200 with warning message |
|
||||
|
||||
## UI Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Generate Granite Loader Sheet [X] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Operation: [Change ▾] Devices: 24 items │
|
||||
│ │
|
||||
│ ┌─ Columns ──────────────────────────────────────────────────┐ │
|
||||
│ │ ▸ Identification (4 selected) │ │
|
||||
│ │ ▸ IP Addressing (2 selected) │ │
|
||||
│ │ ▸ Responsible Org (1 selected) │ │
|
||||
│ │ ▸ Cyber Metrics (0 selected) │ │
|
||||
│ │ ▸ Equipment Info (0 selected) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Enrich from CARD] │
|
||||
│ │
|
||||
│ ┌─ Bulk Defaults ────────────────────────────────────────────┐ │
|
||||
│ │ RESPONSIBLE TEAM: [NTS-AEO-STEAM ] │ │
|
||||
│ │ EQUIP_STATUS: [Active ] │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Preview (24 rows) ────────────────────────────────────────┐ │
|
||||
│ │ IP Address │ EQUIP_INST_ID │ RESP TEAM │ STATUS │ │
|
||||
│ │───────────────┼───────────────┼────────────────┼───────────│ │
|
||||
│ │ 10.240.78.110 │ 1931008 │ NTS-AEO-STEAM │ Active │ │
|
||||
│ │ 10.240.78.111 │ 1931009 │ NTS-AEO-STEAM │ Active │ │
|
||||
│ │ 172.16.5.20 │ ⚠ (not found) │ ACCESS-ENG ● │ Active │ │
|
||||
│ │ 172.16.5.21 │ 2045112 │ NTS-AEO-STEAM │ Active │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚠ 1 row missing EQUIP_INST_ID │
|
||||
│ │
|
||||
│ [Cancel] [Download Loader Sheet] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- ● = per-row override indicator (amber dot)
|
||||
- ⚠ = missing required field or CARD lookup failure
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
**File:** `backend/__tests__/granite-loader-enrichment.property.test.js`
|
||||
|
||||
- **Property 1: Enrichment result count** — For any array of N IPs (1 ≤ N ≤ 200), the response contains exactly N result objects.
|
||||
- **Property 2: Found results have required fields** — For any result where `found === true`, `equip_inst_id` is a non-empty string.
|
||||
- **Property 3: Not-found results have null fields** — For any result where `found === false`, `equip_inst_id` is null.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Column configuration: required columns for each operation type
|
||||
- XLSX generation: correct headers, correct row data, empty cells handled
|
||||
- Bulk default + override merge logic
|
||||
- IP validation
|
||||
- EQUIP_INST_ID numeric validation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `POST /api/card/enrich-batch` with mocked CARD API responses
|
||||
- Auth requirement enforcement
|
||||
- 200-IP limit enforcement
|
||||
122
.kiro/specs/granite-loader-export/requirements.md
Normal file
122
.kiro/specs/granite-loader-export/requirements.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Requirements Document: Granite Loader Sheet Export
|
||||
|
||||
## Introduction
|
||||
|
||||
The STEAM Security Dashboard's Ivanti Queue already stages findings for CARD and GRANITE workflows. Once those items are worked, the next step is often submitting changes to Granite via the Team_Device Loader xlsx uploaded through SNIP XperLoad. Currently this requires manually downloading the 5MB template, finding EQUIP_INST_IDs via SNIP, and hand-filling rows — a tedious process for batches of 20+ devices.
|
||||
|
||||
This feature adds a "Generate Loader Sheet" action accessible from the Ivanti Queue that produces a properly formatted Team_Device Loader xlsx pre-populated with device data from the queue items, optionally enriched with EQUIP_INST_ID and other fields from the CARD API. Users select which columns they need, set bulk defaults with per-row overrides, and download a ready-to-upload xlsx.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Loader_Sheet**: The first worksheet ("Load_Sheet") in the Team_Device Loader xlsx workbook, containing the 41-column format that SNIP XperLoad accepts.
|
||||
- **EQUIP_INST_ID**: The unique Granite identifier for an asset record. Required for Changes, Moves, and Deletes.
|
||||
- **CARD_Enrichment**: The process of looking up an IP address in the CARD API to retrieve EQUIP_INST_ID, hostname, ASN, site, and other Granite-relevant fields.
|
||||
- **Bulk_Default**: A value applied to all rows in a selected column. Can be overridden per-row.
|
||||
- **Operation_Type**: One of Add, Change, Move, or Delete — determines which columns are required.
|
||||
- **XperLoad**: The SNIP bulk loader tool that accepts the Team_Device Loader xlsx.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Generate Loader Sheet Action from Ivanti Queue
|
||||
|
||||
**User Story:** As a security analyst, I want to generate a Granite Loader Sheet directly from my CARD/GRANITE queue items, so that I don't have to manually look up device data and fill the template by hand.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user has one or more CARD or GRANITE queue items selected (pending or completed), THE Queue page SHALL display a "Generate Loader Sheet" action button in the floating action bar.
|
||||
2. WHEN the user clicks "Generate Loader Sheet", THE system SHALL open a Loader Configuration Modal pre-populated with the IP addresses and hostnames from the selected queue items.
|
||||
3. THE action SHALL be available to users in the Admin and Standard_User groups.
|
||||
4. THE action SHALL support any number of selected items from 1 to 200.
|
||||
5. IF no CARD or GRANITE items are selected, THE "Generate Loader Sheet" button SHALL NOT appear.
|
||||
|
||||
### Requirement 2: Operation Type Selection
|
||||
|
||||
**User Story:** As a security analyst, I want to specify what type of Granite operation I'm performing, so that the generated sheet includes the correct required columns.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Loader Configuration Modal SHALL present an Operation Type selector with options: Change, Add, Delete, Move.
|
||||
2. WHEN "Change" is selected, THE system SHALL require EQUIP_INST_ID (or SITE_NAME + EQUIP_NAME) as an identifier and allow any other column as an optional change field.
|
||||
3. WHEN "Add" is selected, THE system SHALL require: EQUIPMENT CLASS, SITE_NAME, EQUIP_NAME, EQUIP_TEMPLATE, EQUIP_STATUS, UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM, UDA#IP_ADDRESSING#IPV4_ADDRESS, UDA#IP_ADDRESSING#MGMT_IP_ASN.
|
||||
4. WHEN "Delete" is selected, THE system SHALL require DELETE column (auto-filled with "X") and EQUIP_INST_ID (or SITE_NAME + EQUIP_NAME).
|
||||
5. WHEN "Move" is selected, THE system SHALL require EQUIP_INST_ID and SITE_NAME (the new site).
|
||||
6. THE default Operation Type SHALL be "Change" since queue items are typically existing assets needing updates.
|
||||
|
||||
### Requirement 3: Column Selection
|
||||
|
||||
**User Story:** As a security analyst, I want to choose which columns to include in my loader sheet, so that I only fill in what's relevant to my specific task.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Loader Configuration Modal SHALL display a list of available columns as checkboxes, grouped by category (Identification, IP Addressing, Discovery, Responsible Org, Cyber Metrics, Equipment Info, Other).
|
||||
2. Columns required by the selected Operation Type SHALL be pre-checked and non-dismissable.
|
||||
3. THE user SHALL be able to check additional optional columns beyond the required set.
|
||||
4. THE generated xlsx SHALL only include columns that are checked (required + user-selected optional).
|
||||
5. THE column order in the generated xlsx SHALL match the canonical Team_Device Loader column order (DELETE first, then SET_CONFIRMED, EQUIPMENT CLASS, EQUIP_INST_ID, etc.).
|
||||
|
||||
### Requirement 4: Device List with Bulk Defaults and Per-Row Overrides
|
||||
|
||||
**User Story:** As a security analyst, I want to set a default value for a column that applies to all rows, but override it on specific rows when devices need different values.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Loader Configuration Modal SHALL display a preview table showing all devices (rows) and selected columns.
|
||||
2. FOR each selected column, THE modal SHALL provide a "Bulk Default" input above the table that sets the value for all rows in that column.
|
||||
3. WHEN a Bulk Default is set or changed, ALL rows in that column that have not been individually overridden SHALL update to the new default value.
|
||||
4. THE user SHALL be able to click any cell in the preview table to edit its value inline, creating a per-row override.
|
||||
5. Cells with per-row overrides SHALL display a visual indicator (amber dot) distinguishing them from bulk-defaulted cells.
|
||||
6. THE user SHALL be able to clear a per-row override to revert a cell back to the bulk default.
|
||||
7. THE preview table SHALL be scrollable for large device lists (20+ items) while keeping column headers and the bulk default row sticky.
|
||||
|
||||
### Requirement 5: CARD API Enrichment
|
||||
|
||||
**User Story:** As a security analyst, I want to automatically look up EQUIP_INST_ID and other Granite fields from the CARD API using the device IP, so that I don't have to manually search SNIP for each device.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Loader Configuration Modal SHALL include an "Enrich from CARD" toggle/button.
|
||||
2. WHEN "Enrich from CARD" is activated, THE system SHALL look up each device's IP address via the CARD API and populate available fields: EQUIP_INST_ID, EQUIP_NAME (hostname), UDA#IP_ADDRESSING#MGMT_IP_ASN, SITE_NAME (if discoverable from CARD data).
|
||||
3. THE enrichment SHALL display a progress indicator while lookups are in progress.
|
||||
4. IF a device IP is not found in CARD, THE system SHALL leave the EQUIP_INST_ID cell empty and display a warning indicator on that row.
|
||||
5. CARD-enriched values SHALL be treated as pre-populated defaults that can still be overridden per-row.
|
||||
6. THE enrichment SHALL NOT overwrite values the user has already manually entered or overridden.
|
||||
7. THE system SHALL handle CARD API errors gracefully — partial enrichment is acceptable (enrich what you can, warn on failures).
|
||||
8. THE CARD enrichment endpoint SHALL require the CARD API to be configured (CARD_API_URL, CARD_API_USER, CARD_API_PASS set in .env). If not configured, the "Enrich from CARD" option SHALL be hidden.
|
||||
|
||||
### Requirement 6: XLSX Generation and Download
|
||||
|
||||
**User Story:** As a security analyst, I want to download a properly formatted xlsx file that I can upload directly to SNIP XperLoad without further editing.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user clicks "Download", THE system SHALL generate an xlsx file with a single worksheet named "Load_Sheet".
|
||||
2. THE first row SHALL contain column headers matching the exact canonical Team_Device Loader column names (e.g., "UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM", not abbreviated).
|
||||
3. Each subsequent row SHALL contain the device data as configured in the preview table (bulk defaults + per-row overrides).
|
||||
4. THE DELETE column SHALL be auto-populated with "X" for every row when Operation Type is "Delete".
|
||||
5. THE EQUIPMENT CLASS column SHALL default to "S" (Shelf) unless overridden.
|
||||
6. THE generated file SHALL be named with a descriptive pattern: `Loader_{operation}_{team}_{date}.xlsx` (e.g., `Loader_Change_NTS-AEO-STEAM_2026-05-27.xlsx`).
|
||||
7. THE xlsx SHALL NOT include the reference data sheets (site names, templates, etc.) — only the Load_Sheet with data. This keeps the file small and focused.
|
||||
8. Empty cells SHALL remain empty (not "null" or "undefined").
|
||||
|
||||
### Requirement 7: Standalone Access (Outside Queue Context)
|
||||
|
||||
**User Story:** As a security analyst, I want to access the Loader Sheet generator without going through the queue, for cases where I have a list of devices that aren't in my queue.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Loader Sheet generator SHALL be accessible as a standalone tool from the navigation drawer or a dedicated page section.
|
||||
2. IN standalone mode, THE user SHALL be able to paste a list of IP addresses (one per line or comma-separated) to populate the device list.
|
||||
3. IN standalone mode, THE user SHALL be able to manually add rows and fill in device identifiers (IP, hostname, or EQUIP_INST_ID).
|
||||
4. ALL other functionality (operation type, column selection, bulk defaults, per-row overrides, CARD enrichment, xlsx download) SHALL work identically in standalone mode and queue-initiated mode.
|
||||
|
||||
### Requirement 8: Validation and Error Prevention
|
||||
|
||||
**User Story:** As a security analyst, I want the tool to warn me about missing required fields before I download, so that I don't upload an incomplete sheet to XperLoad.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. BEFORE generating the xlsx, THE system SHALL validate that all required columns for the selected Operation Type have values in every row.
|
||||
2. IF required fields are missing, THE system SHALL highlight the empty cells in red and display a summary warning (e.g., "3 rows missing EQUIP_INST_ID").
|
||||
3. THE user SHALL be able to proceed with download despite warnings (XperLoad will reject invalid rows anyway), but must explicitly acknowledge the warning.
|
||||
4. THE system SHALL validate IP address format (IPv4 pattern) for the IPV4_ADDRESS column if populated.
|
||||
5. THE system SHALL validate EQUIP_INST_ID is numeric when populated.
|
||||
151
.kiro/specs/granite-loader-export/tasks.md
Normal file
151
.kiro/specs/granite-loader-export/tasks.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Implementation Plan: Granite Loader Sheet Export
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Granite Team_Device Loader xlsx generator accessible from the Ivanti Queue (for CARD/GRANITE items) and as a standalone tool. The system enriches device data from the CARD API, allows bulk defaults with per-row overrides in an editable preview table, and generates a properly formatted xlsx for upload to SNIP XperLoad. Implementation proceeds from column configuration utility → backend enrichment endpoint → frontend modal → queue integration → standalone access.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 1. Create column configuration utility module
|
||||
- [ ] 1.1 Create `frontend/src/utils/graniteLoaderConfig.js`
|
||||
- Define `LOADER_COLUMNS` array with all 41 columns: id, label (exact Granite header name), group, requiredFor array
|
||||
- Define `COLUMN_GROUPS` ordered array for UI grouping
|
||||
- Define `OPERATION_TYPES` array: Change, Add, Delete, Move
|
||||
- Export `getRequiredColumns(operationType)` helper that returns column IDs required for the given operation
|
||||
- Export `getColumnsByGroup(group)` helper that returns columns in a group
|
||||
- _Requirements: 2.1–2.6, 3.1–3.5_
|
||||
|
||||
- [ ] 1.2 Create `frontend/src/utils/graniteLoaderExport.js`
|
||||
- Export `generateLoaderXlsx(config)` function that accepts `{ operationType, columnIds, rows }` and returns a Blob
|
||||
- Use the `xlsx` library (already in frontend dependencies) to create a workbook with a single "Load_Sheet" worksheet
|
||||
- First row contains exact canonical column headers from LOADER_COLUMNS (matched by columnIds, in canonical order)
|
||||
- Subsequent rows contain device data; empty values become empty cells (not "null")
|
||||
- DELETE column auto-filled with "X" when operationType is "Delete"
|
||||
- EQUIPMENT CLASS defaults to "S" unless overridden
|
||||
- Export `generateFilename(operationType, teamName)` helper returning `Loader_{op}_{team}_{YYYY-MM-DD}.xlsx`
|
||||
- _Requirements: 6.1–6.8_
|
||||
|
||||
- [ ] 2. Backend CARD enrichment endpoint
|
||||
- [ ] 2.1 Add `POST /api/card/enrich-batch` endpoint in `backend/routes/cardApi.js`
|
||||
- Accept `{ ips: string[] }` in request body
|
||||
- Validate: ips is a non-empty array, max 200 items, each item is a non-empty string
|
||||
- Require Admin or Standard_User group
|
||||
- Require CARD API to be configured (return 503 if not)
|
||||
- For each IP, attempt owner lookup with known suffixes (CTEC, NATL, CHTR, etc.)
|
||||
- Extract from asset record: equip_inst_id (from ncim_discovery, netops_granite_allips, or ise_granite_equipment), hostname, site_name, mgmt_ip_asn, responsible_team, equipment_class, equip_template, equip_status
|
||||
- Return `{ results: [...], enriched_count, not_found_count, total }`
|
||||
- Handle per-IP errors gracefully (mark as not-found, continue with others)
|
||||
- Handle CARD API auth failures (return 502 with error message)
|
||||
- _Requirements: 5.1–5.8_
|
||||
|
||||
- [ ] 2.2 Add `GET /api/card/configured` endpoint (or extend existing `/api/card/status`)
|
||||
- Return `{ configured: boolean }` so the frontend knows whether to show the "Enrich from CARD" option
|
||||
- This may already exist as `GET /api/card/status` — verify and reuse if so
|
||||
- _Requirements: 5.8_
|
||||
|
||||
- [ ] 3. Frontend LoaderModal component
|
||||
- [ ] 3.1 Create `frontend/src/components/LoaderModal.js`
|
||||
- Accept props: `isOpen`, `onClose`, `initialDevices` (array of `{ ip_address, hostname }` or null)
|
||||
- Render modal overlay with header "Generate Granite Loader Sheet"
|
||||
- Include Operation Type selector (dropdown, defaults to "Change")
|
||||
- Include Column Selection panel with collapsible groups and checkboxes
|
||||
- Required columns for selected operation are pre-checked and disabled
|
||||
- Include "Enrich from CARD" button (hidden if CARD not configured, checked via `/api/card/status` on mount)
|
||||
- Include Bulk Defaults section: one input per selected column, setting value applies to all non-overridden rows
|
||||
- Include editable Preview Table: rows = devices, columns = selected columns
|
||||
- Cells are inline-editable on click; overridden cells show amber dot indicator
|
||||
- Right-click or clear button on overridden cell reverts to bulk default
|
||||
- Sticky column headers and bulk default row when scrolling
|
||||
- Validation: highlight missing required fields in red, show warning count
|
||||
- Download button: merges bulk defaults + overrides into final row data, calls `generateLoaderXlsx`, triggers browser download
|
||||
- Cancel button closes modal
|
||||
- _Requirements: 1.2, 2.1–2.6, 3.1–3.5, 4.1–4.7, 6.1–6.8, 8.1–8.5_
|
||||
|
||||
- [ ] 3.2 Implement CARD enrichment flow in LoaderModal
|
||||
- On "Enrich from CARD" click, collect all device IPs, POST to `/api/card/enrich-batch`
|
||||
- Show progress indicator during request
|
||||
- On response, populate enriched fields into device rows (equip_inst_id, hostname, site_name, mgmt_ip_asn, etc.)
|
||||
- Do NOT overwrite values the user has already manually entered
|
||||
- Show warning indicators on rows where IP was not found
|
||||
- Show error toast if CARD API auth fails
|
||||
- _Requirements: 5.1–5.7_
|
||||
|
||||
- [ ] 3.3 Implement standalone mode (paste IPs)
|
||||
- When `initialDevices` is null, show a textarea for pasting IPs (one per line or comma-separated)
|
||||
- Parse input into device rows on "Load" button click
|
||||
- Allow manually adding/removing rows via + and trash icons
|
||||
- _Requirements: 7.1–7.4_
|
||||
|
||||
- [ ] 4. Integrate with Ivanti Queue page
|
||||
- [ ] 4.1 Add "Generate Loader Sheet" button to IvantiTodoQueuePage floating action bar
|
||||
- Show button when one or more selected items have workflow_type CARD or GRANITE
|
||||
- Button label: "Generate Loader Sheet" with a FileSpreadsheet icon
|
||||
- On click, open LoaderModal with `initialDevices` populated from selected items' ip_address and hostname
|
||||
- _Requirements: 1.1–1.5_
|
||||
|
||||
- [ ] 4.2 Add standalone access point
|
||||
- Add "Granite Loader" link in the navigation drawer under Tools section (or similar)
|
||||
- Clicking opens LoaderModal in standalone mode (initialDevices = null)
|
||||
- Alternatively, add a "Generate Loader Sheet" button on the CARD status section if one exists
|
||||
- _Requirements: 7.1_
|
||||
|
||||
- [ ] 5. Checkpoint — Verify build and basic functionality
|
||||
- Build frontend: `cd frontend && npm run build`
|
||||
- Verify no lint errors or build failures
|
||||
- Ensure all existing tests still pass
|
||||
- Ask the user if questions arise
|
||||
|
||||
- [ ]* 6. Property-based tests for enrichment endpoint
|
||||
- [ ]* 6.1 Write property test: Enrichment result count
|
||||
- **Property 1: Result count equals input count** — For any array of N IPs (1 ≤ N ≤ 200), the response `results` array has exactly N elements
|
||||
- File: `backend/__tests__/granite-loader-enrichment.property.test.js`
|
||||
- **Validates: Requirements 5.1, 5.2**
|
||||
|
||||
- [ ]* 6.2 Write property test: Found results have equip_inst_id
|
||||
- **Property 2: Found results have required fields** — For any result where `found === true`, `equip_inst_id` is a non-empty string
|
||||
- **Validates: Requirements 5.2**
|
||||
|
||||
- [ ]* 6.3 Write property test: Not-found results have null fields
|
||||
- **Property 3: Not-found results have null equip_inst_id** — For any result where `found === false`, `equip_inst_id` is null
|
||||
- **Validates: Requirements 5.4**
|
||||
|
||||
- [ ]* 7. Unit tests for xlsx generation
|
||||
- [ ]* 7.1 Write unit tests for `generateLoaderXlsx`
|
||||
- Test correct column headers in canonical order
|
||||
- Test DELETE column auto-fill for Delete operation
|
||||
- Test EQUIPMENT CLASS defaults to "S"
|
||||
- Test empty values produce empty cells (not "null" string)
|
||||
- Test bulk default + override merge produces correct row values
|
||||
- File: `backend/__tests__/granite-loader-xlsx-generation.test.js`
|
||||
- **Validates: Requirements 6.1–6.8**
|
||||
|
||||
- [ ] 8. Final checkpoint
|
||||
- Build frontend and verify no regressions
|
||||
- Ensure all tests pass
|
||||
- Ask the user if questions arise
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional property-based and unit tests that can be skipped for faster MVP
|
||||
- The `xlsx` library is already a frontend dependency — no new packages needed for xlsx generation
|
||||
- The CARD API enrichment reuses the existing `cardApi.js` helper (token management, TLS skip, etc.)
|
||||
- No database schema changes are required — this feature reads from queue items and CARD API only
|
||||
- The LoaderModal follows the same pattern as ConsolidationModal (modal overlay, form state, action buttons)
|
||||
- The preview table follows the same inline-edit pattern as the Reporting page (click to edit, amber dot for overrides)
|
||||
- Maximum 200 devices per batch aligns with CARD API pagination limits and practical XperLoad batch sizes
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1", "1.2"] },
|
||||
{ "id": 1, "tasks": ["2.1", "2.2"] },
|
||||
{ "id": 2, "tasks": ["3.1"] },
|
||||
{ "id": 3, "tasks": ["3.2", "3.3"] },
|
||||
{ "id": 4, "tasks": ["4.1", "4.2"] },
|
||||
{ "id": 5, "tasks": ["5"] },
|
||||
{ "id": 6, "tasks": ["6.1", "6.2", "6.3", "7.1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "3e04c019-5516-446d-867c-bdb52b4f516a", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||
29
.kiro/specs/ivanti-queue-clear-completed-fix/bugfix.md
Normal file
29
.kiro/specs/ivanti-queue-clear-completed-fix/bugfix.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Bugfix Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The "Clear Completed" button in the Ivanti Queue panel fails silently when clicked. Completed queue items that have associated rows in the `jira_ticket_queue_items` junction table cannot be deleted because the foreign key constraint (`queue_item_id REFERENCES ivanti_todo_queue(id)`) lacks `ON DELETE CASCADE`. PostgreSQL rejects the deletion with a FK violation, the backend returns a 500, and the frontend discards the error — leaving the user with no feedback and no action taken.
|
||||
|
||||
Queue items are marked complete once a Jira ticket has been opened from them. The Jira ticket continues to live independently in the `jira_tickets` table — the junction table link is purely historical at that point. Clearing completed items should remove both the queue item and its junction table references without affecting the Jira ticket itself.
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Current Behavior (Defect)
|
||||
|
||||
1.1 WHEN a user clicks "Clear Completed" and one or more completed queue items have associated rows in `jira_ticket_queue_items` THEN the system returns a 500 error due to a foreign key violation and no items are deleted
|
||||
|
||||
1.2 WHEN the backend DELETE query fails with a FK constraint error THEN the system returns a generic 500 response and the frontend silently ignores the failure, providing no user feedback
|
||||
|
||||
### Expected Behavior (Correct)
|
||||
|
||||
2.1 WHEN a user clicks "Clear Completed" and one or more completed queue items have associated rows in `jira_ticket_queue_items` THEN the system SHALL first delete the associated `jira_ticket_queue_items` rows and then delete the completed queue items successfully within a transaction
|
||||
|
||||
2.2 WHEN the "Clear Completed" operation succeeds THEN the system SHALL return a success response and the frontend SHALL remove all completed items from the displayed list
|
||||
|
||||
### Unchanged Behavior (Regression Prevention)
|
||||
|
||||
3.1 WHEN a user clicks "Clear Completed" and no completed queue items have associated rows in `jira_ticket_queue_items` THEN the system SHALL CONTINUE TO delete those items directly and return a success response
|
||||
|
||||
3.2 WHEN a user clicks "Clear Completed" and there are no completed queue items THEN the system SHALL CONTINUE TO return a success response with zero deleted count
|
||||
|
||||
3.3 WHEN queue items are in a non-complete status (pending, in_progress) THEN the system SHALL CONTINUE TO leave those items untouched regardless of whether they have associated `jira_ticket_queue_items` rows
|
||||
199
.kiro/specs/ivanti-queue-clear-completed-fix/design.md
Normal file
199
.kiro/specs/ivanti-queue-clear-completed-fix/design.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Ivanti Queue Clear Completed Bugfix Design
|
||||
|
||||
## Overview
|
||||
|
||||
The "Clear Completed" endpoint (`DELETE /api/ivanti/todo-queue/completed`) fails with a 500 error when completed queue items have associated rows in the `jira_ticket_queue_items` junction table. The foreign key constraint on `queue_item_id REFERENCES ivanti_todo_queue(id)` lacks `ON DELETE CASCADE`, so PostgreSQL rejects the deletion. The fix wraps the operation in a transaction that deletes junction table references before deleting the queue items themselves.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Bug_Condition (C)**: The condition that triggers the bug — when one or more completed queue items have associated rows in `jira_ticket_queue_items`
|
||||
- **Property (P)**: The desired behavior — all completed items (with or without junction table links) are deleted atomically, along with their junction table references
|
||||
- **Preservation**: Existing behavior for items without junction table links, empty result sets, and non-complete items must remain unchanged
|
||||
- **`pool`**: The PostgreSQL connection pool exported from `backend/db.js`
|
||||
- **`jira_ticket_queue_items`**: Junction table linking `jira_tickets` to `ivanti_todo_queue` items (created by `add_multi_item_jira_ticket.js` migration)
|
||||
- **`ivanti_todo_queue`**: Main queue table storing user work items with `status` column (`pending`, `complete`)
|
||||
|
||||
## Bug Details
|
||||
|
||||
### Bug Condition
|
||||
|
||||
The bug manifests when a user clicks "Clear Completed" and at least one of their completed queue items has a row in `jira_ticket_queue_items` referencing it. The current handler issues a bare `DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'` which PostgreSQL rejects because child rows exist in the junction table.
|
||||
|
||||
**Formal Specification:**
|
||||
```
|
||||
FUNCTION isBugCondition(input)
|
||||
INPUT: input of type ClearCompletedRequest
|
||||
OUTPUT: boolean
|
||||
|
||||
completedItems := SELECT id FROM ivanti_todo_queue
|
||||
WHERE user_id = input.userId AND status = 'complete'
|
||||
|
||||
linkedItems := SELECT queue_item_id FROM jira_ticket_queue_items
|
||||
WHERE queue_item_id IN completedItems
|
||||
|
||||
RETURN linkedItems.length > 0
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
- User has 3 completed items, 2 have junction table links → DELETE fails with FK violation, 0 items deleted (bug)
|
||||
- User has 1 completed item with a junction table link → DELETE fails, item remains (bug)
|
||||
- User has 5 completed items, all have junction table links → DELETE fails, all remain (bug)
|
||||
- User has 3 completed items, none have junction table links → DELETE succeeds (no bug, preservation case)
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Preservation Requirements
|
||||
|
||||
**Unchanged Behaviors:**
|
||||
- Completed items without junction table links are deleted directly and a success response is returned
|
||||
- When no completed items exist, the endpoint returns `{ message: 'Completed items cleared.', deleted: 0 }`
|
||||
- Pending/in-progress items are never touched regardless of junction table links
|
||||
- The `jira_tickets` table is never modified (tickets persist independently of queue items)
|
||||
- Auth middleware (`requireAuth`, `requireGroup`) continues to enforce access control
|
||||
- Response shape remains `{ message: string, deleted: number }`
|
||||
|
||||
**Scope:**
|
||||
All inputs where `isBugCondition` returns false should be completely unaffected by this fix. This includes:
|
||||
- Users with no completed items
|
||||
- Users with completed items that have no junction table references
|
||||
- Any request targeting non-complete statuses
|
||||
- Mouse/UI interactions unrelated to the "Clear Completed" button
|
||||
|
||||
## Hypothesized Root Cause
|
||||
|
||||
Based on the bug description and code inspection, the root cause is confirmed:
|
||||
|
||||
1. **Missing cascade handling in application code**: The `DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'` query does not account for the FK constraint added by the `add_multi_item_jira_ticket.js` migration. The junction table was added after the original endpoint was written.
|
||||
|
||||
2. **FK constraint without ON DELETE CASCADE**: The migration creates `queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id)` without specifying `ON DELETE CASCADE`. This is a deliberate design choice (junction table links should be explicitly managed), but the delete endpoint was never updated to handle it.
|
||||
|
||||
3. **No transaction wrapping**: The current handler uses a single query without a transaction. Even if it attempted to delete junction rows first, without a transaction there would be a race condition window.
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
Property 1: Bug Condition - Clear Completed With Junction Table Links
|
||||
|
||||
_For any_ set of completed queue items belonging to a user where at least one item has associated rows in `jira_ticket_queue_items`, the fixed clear completed operation SHALL delete all associated `jira_ticket_queue_items` rows first, then delete all completed queue items for that user, atomically within a transaction, and return a success response with the correct deleted count.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
Property 2: Preservation - Clear Completed Without Junction Table Links
|
||||
|
||||
_For any_ set of completed queue items belonging to a user where NO items have associated rows in `jira_ticket_queue_items` (or where no completed items exist at all), the fixed clear completed operation SHALL produce the same result as the original function — deleting all completed items and returning a success response with the correct deleted count.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3**
|
||||
|
||||
## Fix Implementation
|
||||
|
||||
### Changes Required
|
||||
|
||||
**File**: `backend/routes/ivantiTodoQueue.js`
|
||||
|
||||
**Function**: `router.delete('/completed', ...)` handler
|
||||
|
||||
**Specific Changes**:
|
||||
|
||||
1. **Acquire a dedicated client from the pool**: Replace `pool.query(...)` with `pool.connect()` to get a client that supports transactions.
|
||||
|
||||
2. **Begin a transaction**: Issue `BEGIN` before any data-modifying queries.
|
||||
|
||||
3. **Select completed item IDs**: Query `SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'` to get the set of IDs to delete.
|
||||
|
||||
4. **Early exit for empty set**: If no completed items exist, commit and return `{ deleted: 0 }` immediately.
|
||||
|
||||
5. **Delete junction table references**: Issue `DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])` with the collected IDs.
|
||||
|
||||
6. **Delete queue items by ID**: Issue `DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])` using the same ID set (more precise than re-filtering by status).
|
||||
|
||||
7. **Commit transaction**: Issue `COMMIT` on success.
|
||||
|
||||
8. **Rollback on error**: Wrap in try/catch, issue `ROLLBACK` on any failure, then return 500.
|
||||
|
||||
9. **Release client**: Always release the client back to the pool in a `finally` block.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Validation Approach
|
||||
|
||||
The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the bug on unfixed code, then verify the fix works correctly and preserves existing behavior.
|
||||
|
||||
### Exploratory Bug Condition Checking
|
||||
|
||||
**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm the FK violation root cause.
|
||||
|
||||
**Test Plan**: Mock the `pool.connect()` / `pool.query()` pattern to simulate the FK constraint violation. Write tests that attempt to clear completed items when junction table references exist and assert the operation fails on unfixed code.
|
||||
|
||||
**Test Cases**:
|
||||
1. **Single linked item**: One completed item with a junction table reference — DELETE fails (will fail on unfixed code)
|
||||
2. **Mixed linked/unlinked items**: Some completed items have links, some don't — DELETE fails for all (will fail on unfixed code)
|
||||
3. **All items linked**: Every completed item has junction table references — DELETE fails (will fail on unfixed code)
|
||||
4. **Multiple links per item**: One completed item with multiple junction table rows — DELETE fails (will fail on unfixed code)
|
||||
|
||||
**Expected Counterexamples**:
|
||||
- The simple DELETE query throws a FK violation error
|
||||
- The catch block returns 500 and no items are deleted
|
||||
- Possible cause confirmed: missing junction table cleanup before parent row deletion
|
||||
|
||||
### Fix Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL input WHERE isBugCondition(input) DO
|
||||
result := clearCompleted_fixed(input)
|
||||
ASSERT result.status = 200
|
||||
ASSERT result.body.deleted = count(completedItems)
|
||||
ASSERT jira_ticket_queue_items has no rows for deleted IDs
|
||||
ASSERT ivanti_todo_queue has no completed rows for user
|
||||
ASSERT pending items unchanged
|
||||
ASSERT jira_tickets table unchanged
|
||||
END FOR
|
||||
```
|
||||
|
||||
### Preservation Checking
|
||||
|
||||
**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function.
|
||||
|
||||
**Pseudocode:**
|
||||
```
|
||||
FOR ALL input WHERE NOT isBugCondition(input) DO
|
||||
ASSERT clearCompleted_original(input) = clearCompleted_fixed(input)
|
||||
END FOR
|
||||
```
|
||||
|
||||
**Testing Approach**: Property-based testing is recommended for preservation checking because:
|
||||
- It generates many test cases automatically across the input domain
|
||||
- It catches edge cases (empty sets, single items, large batches)
|
||||
- It provides strong guarantees that behavior is unchanged for all non-buggy inputs
|
||||
|
||||
**Test Plan**: Observe behavior on UNFIXED code for cases without junction table links (these succeed today), then write property-based tests capturing that behavior.
|
||||
|
||||
**Test Cases**:
|
||||
1. **No completed items**: Verify returns `{ deleted: 0 }` — same as before
|
||||
2. **Completed items without links**: Verify all are deleted and count is correct — same as before
|
||||
3. **Pending items untouched**: Verify non-complete items are never affected — same as before
|
||||
4. **Response shape preserved**: Verify `{ message, deleted }` structure unchanged
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock `pool.connect()` and verify correct query sequence within transaction (BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT)
|
||||
- Verify ROLLBACK is called on any query failure
|
||||
- Verify client is always released in finally block
|
||||
- Test edge case: empty completed set triggers early COMMIT and returns `{ deleted: 0 }`
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
- Generate random sets of queue items (mix of pending/complete, with/without junction links) and verify the transaction deletes exactly the right rows
|
||||
- Generate random configurations without junction links and verify identical behavior to original code
|
||||
- Generate random user IDs and verify isolation (one user's clear doesn't affect another's items)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end test with actual FK constraints: insert queue items + junction rows, call endpoint, verify both tables cleaned up
|
||||
- Verify atomicity: if the junction DELETE succeeds but queue DELETE fails, nothing is committed
|
||||
- Verify the endpoint still works for the simple case (no junction rows)
|
||||
|
||||
**Test file**: `backend/__tests__/ivanti-queue-clear-completed-fix.test.js`
|
||||
86
.kiro/specs/ivanti-queue-clear-completed-fix/tasks.md
Normal file
86
.kiro/specs/ivanti-queue-clear-completed-fix/tasks.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Implementation Plan
|
||||
|
||||
- [x] 1. Write bug condition exploration test
|
||||
- **Property 1: Bug Condition** - FK Violation on Clear Completed With Junction Table Links
|
||||
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists
|
||||
- **DO NOT attempt to fix the test or the code when it fails**
|
||||
- **NOTE**: This test encodes the expected behavior — it will validate the fix when it passes after implementation
|
||||
- **GOAL**: Surface counterexamples that demonstrate the FK violation bug exists
|
||||
- **Scoped PBT Approach**: Scope the property to the concrete failing case — completed queue items that have associated `jira_ticket_queue_items` rows
|
||||
- Bug condition from design: `isBugCondition(input)` returns true when `linkedItems.length > 0` (completed items have junction table references)
|
||||
- Test file: `backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js`
|
||||
- Mock `pool.query` to simulate FK constraint violation when DELETE is issued against `ivanti_todo_queue` while child rows exist in `jira_ticket_queue_items`
|
||||
- Assert that the current (unfixed) handler returns 500 and deletes zero items
|
||||
- Run test on UNFIXED code
|
||||
- **EXPECTED OUTCOME**: Test FAILS (confirms the bug — the handler crashes with FK violation instead of succeeding)
|
||||
- Document counterexamples: "DELETE FROM ivanti_todo_queue fails with FK violation when junction rows reference completed items"
|
||||
- Mark task complete when test is written, run, and failure is documented
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||
- **Property 2: Preservation** - Clear Completed Without Junction Table Links
|
||||
- **IMPORTANT**: Follow observation-first methodology
|
||||
- **GOAL**: Verify that the unfixed code already handles the non-bug-condition cases correctly, establishing a baseline to preserve
|
||||
- Test file: `backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js`
|
||||
- Observe: When no completed items have junction table links, the simple DELETE succeeds and returns correct count
|
||||
- Observe: When no completed items exist, the endpoint returns `{ message: 'Completed items cleared.', deleted: 0 }`
|
||||
- Observe: Pending/in-progress items are never touched by the DELETE
|
||||
- Write property-based tests generating random sets of completed items WITHOUT junction table links and verify:
|
||||
- All completed items for the user are deleted
|
||||
- Response is `{ message: 'Completed items cleared.', deleted: N }` where N matches count
|
||||
- Non-complete items remain untouched
|
||||
- Other users' items remain untouched
|
||||
- Run tests on UNFIXED code
|
||||
- **EXPECTED OUTCOME**: Tests PASS (confirms baseline behavior to preserve)
|
||||
- Mark task complete when tests are written, run, and passing on unfixed code
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
|
||||
- [x] 3. Fix for FK violation on clear completed queue items
|
||||
|
||||
- [x] 3.1 Implement the fix
|
||||
- File: `backend/routes/ivantiTodoQueue.js`
|
||||
- Replace the simple `pool.query(DELETE...)` in the `router.delete('/completed', ...)` handler with a transaction-based approach:
|
||||
- 1. Acquire a dedicated client via `pool.connect()`
|
||||
- 2. Issue `BEGIN`
|
||||
- 3. Select completed item IDs: `SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'`
|
||||
- 4. If no IDs found, `COMMIT` and return `{ deleted: 0 }` early
|
||||
- 5. Delete junction table references: `DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])`
|
||||
- 6. Delete queue items: `DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])`
|
||||
- 7. `COMMIT` on success
|
||||
- 8. `ROLLBACK` on any error, then return 500
|
||||
- 9. Always release client in `finally` block
|
||||
- _Bug_Condition: isBugCondition(input) where completedItems have rows in jira_ticket_queue_items_
|
||||
- _Expected_Behavior: All completed items and their junction references deleted atomically, returns success with correct count_
|
||||
- _Preservation: Items without junction links still deleted; empty sets return deleted: 0; pending items untouched_
|
||||
- _Requirements: 2.1, 2.2, 3.1, 3.2, 3.3_
|
||||
|
||||
- [x] 3.2 Verify bug condition exploration test now passes
|
||||
- **Property 1: Expected Behavior** - FK Violation on Clear Completed With Junction Table Links
|
||||
- **IMPORTANT**: Re-run the SAME test from task 1 — do NOT write a new test
|
||||
- The test from task 1 encodes the expected behavior (successful deletion with junction cleanup)
|
||||
- When this test passes, it confirms the expected behavior is satisfied
|
||||
- Run: `npx jest backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js --run`
|
||||
- **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed — transaction deletes junction rows then queue items)
|
||||
- _Requirements: 2.1, 2.2_
|
||||
|
||||
- [x] 3.3 Verify preservation tests still pass
|
||||
- **Property 2: Preservation** - Clear Completed Without Junction Table Links
|
||||
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||
- Run preservation property tests from step 2
|
||||
- Run: `npx jest backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js --run`
|
||||
- **EXPECTED OUTCOME**: Tests PASS (confirms no regressions — non-linked items still deleted correctly)
|
||||
- Confirm all tests still pass after fix (no regressions)
|
||||
|
||||
- [x] 4. Write unit tests for transaction logic
|
||||
- Test file: `backend/__tests__/ivanti-queue-clear-completed-fix.test.js`
|
||||
- Mock `pool.connect()` and verify correct query sequence: BEGIN → SELECT IDs → DELETE junction → DELETE queue → COMMIT
|
||||
- Verify ROLLBACK is called when any query in the transaction fails
|
||||
- Verify client is always released in the `finally` block (even on error)
|
||||
- Test edge case: empty completed set triggers early COMMIT and returns `{ deleted: 0 }`
|
||||
- Test that response shape remains `{ message: 'Completed items cleared.', deleted: N }`
|
||||
- _Requirements: 2.1, 2.2, 3.1, 3.2_
|
||||
|
||||
- [x] 5. Checkpoint — Ensure all tests pass
|
||||
- Run full test suite: `npx jest backend/__tests__/ivanti-queue-clear-completed-fix --run`
|
||||
- Ensure all property tests and unit tests pass
|
||||
- Ask the user if questions arise
|
||||
1
.kiro/specs/multi-item-jira-ticket/.config.kiro
Normal file
1
.kiro/specs/multi-item-jira-ticket/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "0b0784a9-768e-42f0-ab8f-70b799b7ab67", "workflowType": "requirements-first", "specType": "feature"}
|
||||
408
.kiro/specs/multi-item-jira-ticket/design.md
Normal file
408
.kiro/specs/multi-item-jira-ticket/design.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# Design Document: Multi-Item Jira Ticket Creation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds multi-select capability to the Ivanti Queue page and a consolidation modal that aggregates information from multiple selected queue items into a single Jira ticket. The design builds on the existing `flexible-jira-ticket-creation` infrastructure — reusing the same backend endpoint (`POST /api/jira-tickets/create-in-jira`) and extending the frontend with selection state management, aggregation logic, and a junction table for tracking which queue items contributed to a ticket.
|
||||
|
||||
The backend endpoint contract remains unchanged. All new logic lives in:
|
||||
1. Frontend selection state and UI (checkboxes, floating action bar)
|
||||
2. Frontend aggregation functions (summary/description generation)
|
||||
3. A new junction table and its associated insert logic (post-creation)
|
||||
4. A backend endpoint to record and query junction associations
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Frontend
|
||||
QueuePage[Ivanti Queue Page<br/>Selection Mode + Action Bar]
|
||||
ConsolidationModal[Consolidation Modal<br/>Aggregated Summary/Description]
|
||||
SingleModal[Existing Creation Modal<br/>Single-item flow]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
CreateEndpoint[POST /api/jira-tickets/create-in-jira<br/>Unchanged contract]
|
||||
JunctionEndpoint[POST /api/jira-tickets/:id/queue-items<br/>New: store associations]
|
||||
GetAssocEndpoint[GET /api/ivanti/todo-queue/ticket-links<br/>New: fetch linked tickets]
|
||||
JiraAPI[jiraApi.js → Jira REST API]
|
||||
DB[(PostgreSQL)]
|
||||
end
|
||||
|
||||
QueuePage -->|1 item selected| SingleModal
|
||||
QueuePage -->|2+ items selected| ConsolidationModal
|
||||
ConsolidationModal -->|POST create ticket| CreateEndpoint
|
||||
CreateEndpoint --> JiraAPI
|
||||
JiraAPI -->|issue created| DB
|
||||
ConsolidationModal -->|POST associations| JunctionEndpoint
|
||||
JunctionEndpoint --> DB
|
||||
QueuePage -->|fetch linked tickets| GetAssocEndpoint
|
||||
GetAssocEndpoint --> DB
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Frontend: Selection State Management
|
||||
|
||||
Selection state is managed in the Ivanti Queue Page component using React state:
|
||||
|
||||
```javascript
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
```
|
||||
|
||||
**Selection Mode Toggle:**
|
||||
- A "Select" button in the page toolbar toggles `selectionMode`
|
||||
- When deactivated, `selectedIds` is cleared
|
||||
|
||||
**Select All:**
|
||||
- A header checkbox toggles all currently visible (filtered) queue item IDs into/out of `selectedIds`
|
||||
|
||||
**Floating Action Bar:**
|
||||
- Rendered when `selectionMode === true && selectedIds.size > 0`
|
||||
- Contains: selection count badge, "Create Jira Ticket" button, "Cancel" button
|
||||
|
||||
### Frontend: Consolidation Modal
|
||||
|
||||
The Consolidation Modal is a new component that receives the selected queue items as props and handles aggregation.
|
||||
|
||||
**Props:**
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `items` | Array<QueueItem> | The selected queue items (full objects) |
|
||||
| `onClose` | Function | Close handler |
|
||||
| `onSuccess` | Function | Called with created ticket data on success |
|
||||
|
||||
**Internal State:**
|
||||
|
||||
| Field | Initial Value | Editable |
|
||||
|---|---|---|
|
||||
| `summary` | Generated (see aggregation) | Yes |
|
||||
| `description` | Generated (see aggregation) | Yes |
|
||||
| `cve_id` | First CVE from items | Yes |
|
||||
| `vendor` | Common vendor or empty | Yes |
|
||||
| `source_context` | `ivanti_queue` | No (locked) |
|
||||
| `selectedItems` | Copy of `items` prop | Yes (removable) |
|
||||
|
||||
### Frontend: Aggregation Functions (Pure)
|
||||
|
||||
These are pure functions that can be unit tested and property tested independently.
|
||||
|
||||
**`generateConsolidatedSummary(items)`**
|
||||
|
||||
```javascript
|
||||
function generateConsolidatedSummary(items) {
|
||||
const count = items.length;
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
|
||||
const firstTitle = items[0]?.finding_title || 'Untitled';
|
||||
const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
|
||||
return raw.slice(0, 255);
|
||||
}
|
||||
```
|
||||
|
||||
**`generateConsolidatedDescription(items)`**
|
||||
|
||||
```javascript
|
||||
function generateConsolidatedDescription(items) {
|
||||
const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
|
||||
|
||||
// Group by vendor
|
||||
const grouped = {};
|
||||
for (const item of items) {
|
||||
const vendor = item.vendor || 'Unknown Vendor';
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(item);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
for (const [vendor, vendorItems] of Object.entries(grouped)) {
|
||||
body += `== ${vendor} ==\n`;
|
||||
for (const item of vendorItems) {
|
||||
const cves = item.cves_json ? JSON.parse(item.cves_json).join(', ') : 'None';
|
||||
body += `- ${item.finding_title}\n`;
|
||||
body += ` CVEs: ${cves}\n`;
|
||||
body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return header + body;
|
||||
}
|
||||
```
|
||||
|
||||
**`extractFirstCve(items)`**
|
||||
|
||||
```javascript
|
||||
function extractFirstCve(items) {
|
||||
for (const item of items) {
|
||||
if (item.cves_json) {
|
||||
const cves = JSON.parse(item.cves_json);
|
||||
if (Array.isArray(cves) && cves.length > 0) return cves[0];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
**`extractCommonVendor(items)`**
|
||||
|
||||
```javascript
|
||||
function extractCommonVendor(items) {
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
return vendors.length === 1 ? vendors[0] : '';
|
||||
}
|
||||
```
|
||||
|
||||
### Backend: Junction Table Endpoints (New)
|
||||
|
||||
**`POST /api/jira-tickets/:id/queue-items`**
|
||||
|
||||
Records the association between a Jira ticket and the queue items that contributed to it.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `queue_item_ids` | number[] | Yes | Array of ivanti_todo_queue IDs |
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"queue_item_ids": [12, 15, 18, 22]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"message": "Queue items linked to ticket",
|
||||
"ticket_id": 42,
|
||||
"linked_count": 4
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `queue_item_ids` must be a non-empty array of integers
|
||||
- All referenced queue items must exist
|
||||
- The jira_ticket must exist
|
||||
- Duplicate associations are ignored (ON CONFLICT DO NOTHING)
|
||||
|
||||
**`GET /api/ivanti/todo-queue/ticket-links`**
|
||||
|
||||
Returns ticket associations for the current user's queue items.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"links": {
|
||||
"12": { "ticket_key": "VULN-789", "jira_url": "https://jira.example.com/browse/VULN-789" },
|
||||
"15": { "ticket_key": "VULN-789", "jira_url": "https://jira.example.com/browse/VULN-789" },
|
||||
"22": { "ticket_key": "VULN-801", "jira_url": "https://jira.example.com/browse/VULN-801" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns a map of queue_item_id → ticket info for all queue items belonging to the authenticated user that have an associated Jira ticket.
|
||||
|
||||
### Frontend: Submission Flow
|
||||
|
||||
1. User clicks "Create Jira Ticket" in Consolidation Modal
|
||||
2. Frontend sends `POST /api/jira-tickets/create-in-jira` with:
|
||||
- `summary`: user-edited summary
|
||||
- `description`: user-edited aggregated description
|
||||
- `cve_id`: first CVE (or null)
|
||||
- `vendor`: common vendor (or null)
|
||||
- `source_context`: `ivanti_queue`
|
||||
3. On 201 response, frontend sends `POST /api/jira-tickets/:id/queue-items` with the selected queue item IDs
|
||||
4. On success, modal closes, success toast shown, queue page refreshes ticket link badges
|
||||
|
||||
### Frontend: Ticket Link Badge on Queue Items
|
||||
|
||||
When the queue page loads, it fetches `GET /api/ivanti/todo-queue/ticket-links` and stores the result in state. For each queue item row, if a link exists, a small badge (e.g., `VULN-789`) is rendered. Clicking the badge opens the Jira URL in a new tab.
|
||||
|
||||
## Data Models
|
||||
|
||||
### `jira_ticket_queue_items` Table (New)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (jira_ticket_id, queue_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
||||
ON jira_ticket_queue_items(queue_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
||||
ON jira_ticket_queue_items(jira_ticket_id);
|
||||
```
|
||||
|
||||
**Foreign Key Behavior:**
|
||||
- `jira_ticket_id` references `jira_tickets(id)` — no CASCADE delete (association preserved even if ticket is deleted from local DB, though this is unlikely)
|
||||
- `queue_item_id` references `ivanti_todo_queue(id)` — no CASCADE delete (association preserved even if queue item is completed/deleted)
|
||||
|
||||
### Migration: `add_multi_item_jira_ticket.js`
|
||||
|
||||
```javascript
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting multi-item Jira ticket migration...');
|
||||
|
||||
// Verify prerequisite tables exist
|
||||
const { rows: jiraTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables WHERE table_name = 'jira_tickets'
|
||||
`);
|
||||
if (jiraTable.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { rows: queueTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables WHERE table_name = 'ivanti_todo_queue'
|
||||
`);
|
||||
if (queueTable.length === 0) {
|
||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create junction table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (jira_ticket_id, queue_item_id)
|
||||
)
|
||||
`);
|
||||
console.log('✓ jira_ticket_queue_items table created (or already exists)');
|
||||
|
||||
// Add indexes
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
||||
ON jira_ticket_queue_items(queue_item_id)
|
||||
`);
|
||||
console.log('✓ queue_item_id index created (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
||||
ON jira_ticket_queue_items(jira_ticket_id)
|
||||
`);
|
||||
console.log('✓ jira_ticket_id index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
### Property 1: Summary generation format and truncation
|
||||
|
||||
*For any* non-empty array of queue items (each with optional vendor and finding_title of arbitrary length), `generateConsolidatedSummary` SHALL return a string that:
|
||||
- Starts with `[N findings]` where N equals the array length
|
||||
- Is at most 255 characters long
|
||||
- Contains the common vendor name when all items share the same vendor, or "Multiple Vendors" when vendors differ
|
||||
|
||||
**Validates: Requirements 3.1, 3.2**
|
||||
|
||||
### Property 2: Description includes all items
|
||||
|
||||
*For any* non-empty array of queue items, `generateConsolidatedDescription` SHALL produce a string that contains the `finding_title` of every item in the input array.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
### Property 3: Description groups by vendor
|
||||
|
||||
*For any* array of queue items with two or more distinct vendors, `generateConsolidatedDescription` SHALL produce a string where all items sharing the same vendor appear in a contiguous block (not interleaved with items from other vendors).
|
||||
|
||||
**Validates: Requirements 4.3**
|
||||
|
||||
### Property 4: Description header contains item count
|
||||
|
||||
*For any* non-empty array of queue items of length N, `generateConsolidatedDescription` SHALL produce a string containing the substring representing N (the count of items).
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 5: First CVE extraction
|
||||
|
||||
*For any* array of queue items, `extractFirstCve` SHALL return the first element of the first non-empty `cves_json` array encountered in item order, or an empty string if no items have CVEs.
|
||||
|
||||
**Validates: Requirements 4.6**
|
||||
|
||||
### Property 6: Common vendor extraction
|
||||
|
||||
*For any* array of queue items where all items have the same non-null vendor value, `extractCommonVendor` SHALL return that vendor value. For any array where items have two or more distinct vendor values, `extractCommonVendor` SHALL return an empty string.
|
||||
|
||||
**Validates: Requirements 4.7**
|
||||
|
||||
### Property 7: Junction table row count equals selected item count
|
||||
|
||||
*For any* successful consolidated ticket creation with N selected queue items (N >= 2), the `jira_ticket_queue_items` table SHALL contain exactly N rows with the created ticket's ID.
|
||||
|
||||
**Validates: Requirements 5.3, 6.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | HTTP Status | Error Message | Client Behavior |
|
||||
|---|---|---|---|
|
||||
| No queue items selected | N/A (frontend) | "Select at least one item" | Button disabled, no request sent |
|
||||
| Fewer than 2 items in consolidation modal | N/A (frontend) | "At least 2 items required for consolidation" | Submit button disabled |
|
||||
| Empty summary on submit | 400 | "Summary is required (max 255 chars)." | Inline error, preserve form |
|
||||
| Jira API unavailable | 502 | "Failed to create Jira issue." | Error banner, preserve form for retry |
|
||||
| Jira rate limit exceeded | 429 | "Jira rate limit exceeded. Try again later." | Error banner, preserve form |
|
||||
| Junction insert fails after ticket creation | 207 (partial) | Warning with ticket key/URL | Show ticket link, warn that associations failed |
|
||||
| Invalid queue_item_ids in junction request | 400 | "queue_item_ids must be a non-empty array of integers" | Display error |
|
||||
| Referenced queue items do not exist | 400 | "One or more queue items not found" | Display error |
|
||||
| Prerequisite tables missing (migration) | Exit code 1 | Console error | Migration aborts cleanly |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
- Selection mode toggle shows/hides checkboxes
|
||||
- Select All selects all visible items
|
||||
- Deactivating selection mode clears selections
|
||||
- Floating action bar appears when items selected, hidden when none selected
|
||||
- "Create Jira Ticket" opens consolidation modal for 2+ items
|
||||
- "Create Jira Ticket" opens single-item modal for exactly 1 item
|
||||
- Consolidation modal renders with correct initial summary, description, CVE, vendor
|
||||
- Consolidation modal allows editing summary and description
|
||||
- Consolidation modal prevents submission with empty summary
|
||||
- Consolidation modal source_context is read-only and set to ivanti_queue
|
||||
- Removing items from modal updates preview list and regenerates summary/description
|
||||
- Removing items below 2 disables submit
|
||||
- Ticket link badge renders on queue items with associations
|
||||
- Clicking ticket link badge opens URL in new tab
|
||||
- Junction endpoint rejects empty or non-array queue_item_ids
|
||||
- Junction endpoint rejects non-existent queue item IDs
|
||||
- Migration creates table with correct schema
|
||||
- Migration is idempotent (running twice succeeds)
|
||||
- Migration fails gracefully when prerequisite tables missing
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based testing is appropriate for the aggregation functions because they operate on arrays of arbitrary length with arbitrary string content — a wide input space where universal format properties must hold.
|
||||
|
||||
**Library:** fast-check (already available in the project's test infrastructure via Jest)
|
||||
|
||||
**Configuration:**
|
||||
- Minimum 100 iterations per property test
|
||||
- Each test tagged with: `Feature: multi-item-jira-ticket, Property {N}: {title}`
|
||||
|
||||
Properties 1–6 test pure aggregation functions (frontend logic extracted into testable modules).
|
||||
Property 7 tests the backend junction insert invariant (mockable DB).
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end: select 3 items → create consolidated ticket → verify junction table has 3 rows
|
||||
- End-to-end: verify ticket-links endpoint returns correct associations after creation
|
||||
- Migration runs successfully on database with existing jira_tickets and ivanti_todo_queue rows
|
||||
- Unique constraint prevents duplicate junction entries (ON CONFLICT DO NOTHING)
|
||||
114
.kiro/specs/multi-item-jira-ticket/requirements.md
Normal file
114
.kiro/specs/multi-item-jira-ticket/requirements.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The STEAM Security Dashboard currently allows users to create a Jira ticket from a single Ivanti queue item via the "Create Jira Ticket" action (implemented in the `flexible-jira-ticket-creation` spec). In practice, multiple queue items often relate to the same remediation effort — same vendor, same CVE, or same host group. Users need the ability to select multiple Ivanti queue items and create ONE consolidated Jira ticket whose summary and description aggregate information from all selected items. This is not batch creation (one ticket per item) — it is consolidation (N items into 1 ticket).
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Ivanti_Queue_Page**: The frontend page where users work through their Ivanti finding queue items.
|
||||
- **Queue_Item**: A single row in the `ivanti_todo_queue` table representing a finding assigned to a user for triage.
|
||||
- **Selection_Mode**: A UI state on the Ivanti_Queue_Page where checkboxes appear on each Queue_Item, allowing the user to select multiple items.
|
||||
- **Consolidation_Modal**: The frontend modal dialog used to create a single Jira ticket from multiple selected Queue_Items, extending the existing Creation_Modal with aggregation logic.
|
||||
- **Ticket_Creation_Service**: The backend endpoint (`POST /api/jira-tickets/create-in-jira`) responsible for creating issues in Jira and storing the local record.
|
||||
- **Aggregated_Description**: A structured text block composed from the selected Queue_Items' metadata (finding titles, CVEs, hostnames, IP addresses, vendor) formatted for the Jira issue description.
|
||||
- **Dashboard**: The STEAM Security Dashboard application.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Multi-Select Mode on Ivanti Queue
|
||||
|
||||
**User Story:** As a security analyst, I want to select multiple items from my Ivanti queue, so that I can act on them as a group.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Ivanti_Queue_Page SHALL display a "Select" toggle button that activates Selection_Mode.
|
||||
2. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a checkbox on each Queue_Item row.
|
||||
3. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a selection count indicator showing the number of currently selected items.
|
||||
4. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a "Select All" checkbox in the table header that toggles selection of all visible Queue_Items.
|
||||
5. WHEN the user deactivates Selection_Mode, THE Ivanti_Queue_Page SHALL clear all selections and hide the checkboxes.
|
||||
6. THE Ivanti_Queue_Page SHALL preserve the selected items when the user scrolls or when the list re-renders due to filter changes within the same session.
|
||||
|
||||
### Requirement 2: Create Consolidated Jira Ticket Action
|
||||
|
||||
**User Story:** As a security analyst, I want to create a single Jira ticket from my selected queue items, so that I can track a group of related findings as one work item.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE Selection_Mode is active and at least one Queue_Item is selected, THE Ivanti_Queue_Page SHALL display a "Create Jira Ticket" action button in a floating action bar.
|
||||
2. WHEN no Queue_Items are selected, THE Ivanti_Queue_Page SHALL disable the "Create Jira Ticket" action button.
|
||||
3. WHEN the user activates the "Create Jira Ticket" action with multiple items selected, THE Ivanti_Queue_Page SHALL open the Consolidation_Modal.
|
||||
4. WHEN the user activates the "Create Jira Ticket" action with exactly one item selected, THE Ivanti_Queue_Page SHALL open the existing single-item Creation_Modal with pre-populated fields from that item (existing behavior from flexible-jira-ticket-creation spec).
|
||||
|
||||
### Requirement 3: Aggregated Summary Generation
|
||||
|
||||
**User Story:** As a security analyst, I want the consolidated ticket summary to clearly indicate it covers multiple findings, so that I can identify multi-item tickets at a glance.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL pre-populate the Summary field with a generated value in the format: "[N findings] vendor_name - first_finding_title" where N is the count of selected items, vendor_name is the common vendor (if all items share the same vendor) or "Multiple Vendors", and first_finding_title is the finding_title of the first selected item.
|
||||
2. THE Consolidation_Modal SHALL truncate the generated summary to 255 characters.
|
||||
3. THE Consolidation_Modal SHALL allow the user to edit the pre-populated summary before submission.
|
||||
4. IF the user clears the summary field and attempts to submit, THEN THE Consolidation_Modal SHALL prevent submission and display an inline error indicating that Summary is required.
|
||||
|
||||
### Requirement 4: Aggregated Description Generation
|
||||
|
||||
**User Story:** As a security analyst, I want the consolidated ticket description to list details from each selected item, so that the Jira ticket contains all relevant finding information.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL generate an Aggregated_Description containing a structured list of all selected Queue_Items.
|
||||
2. THE Aggregated_Description SHALL include for each Queue_Item: finding_title, CVE IDs (from cves_json), hostname, IP address, and vendor.
|
||||
3. THE Aggregated_Description SHALL group items by vendor when multiple vendors are present.
|
||||
4. THE Aggregated_Description SHALL include a header line stating the total count of findings included.
|
||||
5. THE Consolidation_Modal SHALL display the Aggregated_Description in an editable text area, allowing the user to modify it before submission.
|
||||
6. THE Consolidation_Modal SHALL pre-populate the CVE ID field with the first CVE found across all selected items (from the first item's cves_json array), or leave it blank if no items have CVEs.
|
||||
7. THE Consolidation_Modal SHALL pre-populate the Vendor field with the common vendor if all selected items share the same vendor, or leave it blank if vendors differ.
|
||||
|
||||
### Requirement 5: Consolidated Ticket Creation via Backend
|
||||
|
||||
**User Story:** As a security analyst, I want the consolidated Jira ticket to be created through the existing ticket creation endpoint, so that it is tracked and synced like any other Jira ticket.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user submits the Consolidation_Modal, THE Dashboard SHALL send a create-ticket request to the Ticket_Creation_Service with source_context set to `ivanti_queue`, the user-provided summary, and the Aggregated_Description as the description field.
|
||||
2. THE Ticket_Creation_Service SHALL accept the request and create the Jira issue using the existing creation flow without modification to the backend endpoint contract.
|
||||
3. WHEN the Jira ticket is created successfully, THE Dashboard SHALL store a local record linking the ticket to the selected Queue_Item IDs via a new junction table.
|
||||
4. WHEN the Jira ticket is created successfully, THE Dashboard SHALL display a success notification with the created ticket key and a link to the Jira issue.
|
||||
5. IF the Jira API returns an error, THEN THE Consolidation_Modal SHALL display an error message and preserve all form field values for retry.
|
||||
|
||||
### Requirement 6: Queue Item to Ticket Association
|
||||
|
||||
**User Story:** As a security analyst, I want to see which Jira ticket was created from my queue items, so that I can track the relationship between findings and tickets.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL include a `jira_ticket_queue_items` junction table that associates a jira_ticket ID with one or more Queue_Item IDs.
|
||||
2. WHEN a consolidated Jira ticket is created, THE Dashboard SHALL insert one row per selected Queue_Item into the junction table.
|
||||
3. WHILE viewing a Queue_Item that has an associated Jira ticket, THE Ivanti_Queue_Page SHALL display the linked ticket key as a badge or indicator on that item's row.
|
||||
4. WHEN the user clicks the ticket key indicator on a Queue_Item, THE Ivanti_Queue_Page SHALL navigate to or open the Jira ticket URL in a new tab.
|
||||
5. THE junction table association SHALL persist regardless of whether the Queue_Item is later marked complete or deleted.
|
||||
|
||||
### Requirement 7: Database Migration for Junction Table
|
||||
|
||||
**User Story:** As a database administrator, I want the queue-item-to-ticket association stored in a proper junction table, so that the relationship is queryable and maintainable.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL include a migration that creates a `jira_ticket_queue_items` table with columns: id (SERIAL PRIMARY KEY), jira_ticket_id (INTEGER NOT NULL REFERENCES jira_tickets(id)), queue_item_id (INTEGER NOT NULL REFERENCES ivanti_todo_queue(id)), and created_at (TIMESTAMPTZ DEFAULT NOW()).
|
||||
2. THE migration SHALL add a unique constraint on (jira_ticket_id, queue_item_id) to prevent duplicate associations.
|
||||
3. THE migration SHALL add an index on queue_item_id for efficient lookup of tickets associated with a given queue item.
|
||||
4. THE migration SHALL be idempotent — running it multiple times SHALL produce the same schema state without raising errors.
|
||||
5. IF the `jira_tickets` or `ivanti_todo_queue` tables do not exist when the migration runs, THEN THE migration SHALL exit with an error message indicating the prerequisite tables are missing.
|
||||
|
||||
### Requirement 8: Consolidation Modal Source Context and Field Locking
|
||||
|
||||
**User Story:** As a security analyst, I want the consolidated ticket to automatically track its origin as the Ivanti queue, so that I do not need to manually set the source context.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL set source_context to `ivanti_queue` and display it as read-only.
|
||||
2. THE Consolidation_Modal SHALL display the count of selected items in the modal header or subtitle.
|
||||
3. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL display a scrollable preview list of the selected Queue_Items showing finding_title and hostname for each.
|
||||
4. THE Consolidation_Modal SHALL allow the user to remove individual items from the selection within the modal before submission, with a minimum of 2 items required for consolidation.
|
||||
5. IF the user removes items until only 1 remains, THEN THE Consolidation_Modal SHALL display a message indicating that at least 2 items are required for consolidation and disable the submit button.
|
||||
124
.kiro/specs/multi-item-jira-ticket/tasks.md
Normal file
124
.kiro/specs/multi-item-jira-ticket/tasks.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Implementation Plan: Multi-Item Jira Ticket Creation
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements multi-select on the Ivanti Queue page and a consolidation modal that creates a single Jira ticket from multiple selected queue items. The implementation proceeds from database migration → backend junction endpoints → frontend aggregation logic → frontend selection UI → consolidation modal → ticket link badges.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Database migration for junction table
|
||||
- [x] 1.1 Create migration file `backend/migrations/add_multi_item_jira_ticket.js`
|
||||
- Verify `jira_tickets` and `ivanti_todo_queue` tables exist; exit with error if missing
|
||||
- Create `jira_ticket_queue_items` table with columns: id (SERIAL PRIMARY KEY), jira_ticket_id (INTEGER NOT NULL REFERENCES jira_tickets(id)), queue_item_id (INTEGER NOT NULL REFERENCES ivanti_todo_queue(id)), created_at (TIMESTAMPTZ DEFAULT NOW())
|
||||
- Add UNIQUE constraint on (jira_ticket_id, queue_item_id)
|
||||
- Add index on queue_item_id
|
||||
- Add index on jira_ticket_id
|
||||
- Ensure full idempotency — safe to run multiple times
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 2. Backend junction table endpoints
|
||||
- [x] 2.1 Add `POST /api/jira-tickets/:id/queue-items` endpoint in `backend/routes/jiraTickets.js`
|
||||
- Validate `queue_item_ids` is a non-empty array of integers
|
||||
- Verify the jira_ticket exists
|
||||
- Verify all referenced queue items exist
|
||||
- Insert rows into `jira_ticket_queue_items` with ON CONFLICT DO NOTHING
|
||||
- Return 201 with linked_count
|
||||
- Require Admin or Standard_User group
|
||||
- _Requirements: 5.3, 6.1, 6.2_
|
||||
|
||||
- [x] 2.2 Add `GET /api/ivanti/todo-queue/ticket-links` endpoint in `backend/routes/ivantiTodoQueue.js`
|
||||
- Join `jira_ticket_queue_items` with `jira_tickets` to get ticket_key and url
|
||||
- Filter by queue items belonging to the authenticated user
|
||||
- Return a map of queue_item_id → { ticket_key, jira_url }
|
||||
- _Requirements: 6.3, 6.4_
|
||||
|
||||
- [x] 3. Frontend aggregation utility functions
|
||||
- [x] 3.1 Create `frontend/src/utils/jiraConsolidation.js` with pure functions
|
||||
- `generateConsolidatedSummary(items)` — format: `[N findings] vendor - title`, truncated to 255 chars
|
||||
- `generateConsolidatedDescription(items)` — structured description grouped by vendor
|
||||
- `extractFirstCve(items)` — first CVE from first item with non-empty cves_json
|
||||
- `extractCommonVendor(items)` — common vendor if all same, empty string otherwise
|
||||
- _Requirements: 3.1, 3.2, 4.1, 4.2, 4.3, 4.4, 4.6, 4.7_
|
||||
|
||||
- [ ]* 3.2 Write property tests for aggregation functions (Properties 1–6)
|
||||
- **Property 1: Summary format and truncation** — starts with `[N findings]`, at most 255 chars, contains correct vendor label
|
||||
- **Property 2: Description includes all items** — every item's finding_title appears in output
|
||||
- **Property 3: Description groups by vendor** — items with same vendor are contiguous
|
||||
- **Property 4: Description header contains count** — output contains the item count
|
||||
- **Property 5: First CVE extraction** — returns first CVE from first item with CVEs, or empty string
|
||||
- **Property 6: Common vendor extraction** — returns vendor when all same, empty when different
|
||||
- **Validates: Requirements 3.1, 3.2, 4.1, 4.2, 4.3, 4.4, 4.6, 4.7**
|
||||
- File: `backend/__tests__/jira-consolidation-aggregation.property.test.js`
|
||||
|
||||
- [x] 4. Frontend multi-select mode on Ivanti Queue page
|
||||
- [x] 4.1 Add selection mode toggle and checkbox UI to `frontend/src/components/pages/IvantiQueuePage.js`
|
||||
- Add "Select" toggle button to page toolbar
|
||||
- Show checkboxes on each queue item row when selection mode active
|
||||
- Add "Select All" checkbox in table header
|
||||
- Display selection count indicator
|
||||
- Clear selections when selection mode deactivated
|
||||
- Preserve selections on scroll/re-render within same session
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||
|
||||
- [x] 4.2 Add floating action bar with "Create Jira Ticket" button
|
||||
- Render floating bar when selection mode active and at least 1 item selected
|
||||
- Disable "Create Jira Ticket" button when no items selected
|
||||
- Route to existing single-item modal when exactly 1 item selected
|
||||
- Route to new Consolidation Modal when 2+ items selected
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [x] 5. Frontend Consolidation Modal
|
||||
- [x] 5.1 Create `frontend/src/components/ConsolidationModal.js`
|
||||
- Accept selected queue items as props
|
||||
- Call aggregation functions to pre-populate summary, description, cve_id, vendor
|
||||
- Lock source_context to `ivanti_queue` (read-only display)
|
||||
- Display item count in modal header/subtitle
|
||||
- Render scrollable preview list of selected items (finding_title + hostname)
|
||||
- Allow removing individual items from selection (minimum 2 required)
|
||||
- Disable submit and show message when fewer than 2 items remain
|
||||
- Editable summary field with required validation (max 255 chars)
|
||||
- Editable description textarea
|
||||
- Editable CVE ID and Vendor fields (optional)
|
||||
- On submit: POST to create-in-jira, then POST to junction endpoint
|
||||
- On success: close modal, show success toast, trigger queue page refresh
|
||||
- On error: display error message, preserve form values
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 4.1, 4.5, 5.1, 5.2, 5.4, 5.5, 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 6. Frontend ticket link badges on queue items
|
||||
- [x] 6.1 Fetch and display ticket link badges on Ivanti Queue page
|
||||
- On page load, fetch `GET /api/ivanti/todo-queue/ticket-links`
|
||||
- For each queue item with an association, render a ticket key badge (e.g., "VULN-789")
|
||||
- Clicking badge opens Jira URL in new tab
|
||||
- Refresh links after successful consolidated ticket creation
|
||||
- _Requirements: 6.3, 6.4, 6.5_
|
||||
|
||||
- [ ] 7. Backend property test for junction insert invariant
|
||||
- [ ]* 7.1 Write property test for junction row count (Property 7)
|
||||
- **Property 7: Junction table row count equals selected item count** — for N items, exactly N rows inserted
|
||||
- **Validates: Requirements 5.3, 6.2**
|
||||
- File: `backend/__tests__/jira-consolidation-junction.property.test.js`
|
||||
|
||||
- [x] 8. Final checkpoint
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional property-based tests that can be skipped for faster MVP
|
||||
- The existing `POST /api/jira-tickets/create-in-jira` endpoint is reused without modification
|
||||
- The aggregation functions are pure and extracted into a utility module for easy testing
|
||||
- The junction table insert happens as a second request after ticket creation — if it fails, the ticket still exists in Jira (partial success scenario handled with warning)
|
||||
- The Consolidation Modal is a separate component from the existing Creation Modal to avoid overcomplicating the single-item flow
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["2.1", "2.2", "3.1"] },
|
||||
{ "id": 2, "tasks": ["3.2", "4.1"] },
|
||||
{ "id": 3, "tasks": ["4.2", "5.1"] },
|
||||
{ "id": 4, "tasks": ["6.1", "7.1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/queue-collapsible-sections/.config.kiro
Normal file
1
.kiro/specs/queue-collapsible-sections/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "e9cd98e7-7a78-4795-a7a7-d5b6358dd10d", "workflowType": "fast-task", "specType": "feature"}
|
||||
298
.kiro/specs/queue-collapsible-sections/design.md
Normal file
298
.kiro/specs/queue-collapsible-sections/design.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Queue Collapsible Sections — Design
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds collapsible, grouped sections to the IvantiTodoQueuePage. Items are organized into a hybrid layout: an "Inventory" section (CARD, GRANITE, DECOM workflow types) at the top, followed by vendor-grouped sections for FP and Archer items. Each section has a clickable header that toggles visibility of its items. The implementation is entirely frontend — no backend or database changes are needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
The feature modifies a single file: `frontend/src/components/pages/IvantiTodoQueuePage.js`. It introduces:
|
||||
|
||||
1. A **grouping computation** (via `useMemo`) that transforms the flat `visibleItems` array into a structured array of sections.
|
||||
2. A **collapse state** (via `useState`) that tracks which sections are expanded or collapsed.
|
||||
3. **Section header components** rendered inline that display group labels, item counts, and chevron icons.
|
||||
4. **Extended styles** added to the existing `STYLES` constant.
|
||||
|
||||
No new files, components, or dependencies are introduced. The existing selection logic, floating action bar, consolidation modal, and ticket link badges continue to operate unchanged.
|
||||
|
||||
## Components and Data Flow
|
||||
|
||||
```
|
||||
queueItems (from API)
|
||||
│
|
||||
▼
|
||||
visibleItems = useMemo(filter pending) ← existing
|
||||
│
|
||||
▼
|
||||
groupedSections = useMemo(groupItems) ← NEW: hybrid grouping logic
|
||||
│
|
||||
▼
|
||||
collapsedSections (useState) ← NEW: collapse state
|
||||
│
|
||||
▼
|
||||
Render: map over groupedSections
|
||||
├── Section Header (clickable, toggles collapse)
|
||||
└── Section Body (conditionally rendered based on collapse state)
|
||||
└── Queue Item Rows (existing rendering logic)
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Grouped Section Structure
|
||||
|
||||
The `useMemo` grouping hook produces an array of section objects:
|
||||
|
||||
```javascript
|
||||
// Output of the grouping computation
|
||||
const groupedSections = [
|
||||
{
|
||||
key: 'inventory', // Unique section identifier
|
||||
label: 'Inventory', // Display label
|
||||
type: 'inventory', // Section type for styling
|
||||
items: [/* queue items */], // Items belonging to this section
|
||||
},
|
||||
{
|
||||
key: 'vendor:Microsoft', // Unique section identifier
|
||||
label: 'Microsoft', // Display label (vendor name)
|
||||
type: 'vendor', // Section type for styling
|
||||
items: [/* queue items */], // Items belonging to this section
|
||||
},
|
||||
// ... more vendor sections, alphabetically sorted
|
||||
];
|
||||
```
|
||||
|
||||
### Collapse State
|
||||
|
||||
```javascript
|
||||
// Key: section.key string, Value: boolean (true = collapsed)
|
||||
const [collapsedSections, setCollapsedSections] = useState({});
|
||||
// Initial state: empty object → all sections default to expanded
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Grouping Function
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Groups visible queue items into the hybrid section layout.
|
||||
*
|
||||
* @param {Array} visibleItems - Queue items with status 'pending'
|
||||
* @returns {Array<{key: string, label: string, type: string, items: Array}>}
|
||||
*
|
||||
* Rules:
|
||||
* - Items with workflow_type CARD, GRANITE, or DECOM → Inventory section
|
||||
* - Items with workflow_type FP or Archer → grouped by vendor field
|
||||
* - Items with null/undefined/empty vendor → placed in "Unknown" vendor section
|
||||
* - Inventory section appears first (if non-empty)
|
||||
* - Vendor sections sorted alphabetically by label
|
||||
* - Sections with zero items are omitted from output
|
||||
*/
|
||||
const groupedSections = useMemo(() => {
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
|
||||
const inventoryItems = [];
|
||||
const vendorMap = new Map(); // vendor name → items array
|
||||
|
||||
for (const item of visibleItems) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
inventoryItems.push(item);
|
||||
} else {
|
||||
const vendor = item.vendor?.trim() || 'Unknown';
|
||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
||||
vendorMap.get(vendor).push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
|
||||
// Inventory section first (only if non-empty)
|
||||
if (inventoryItems.length > 0) {
|
||||
sections.push({
|
||||
key: 'inventory',
|
||||
label: 'Inventory',
|
||||
type: 'inventory',
|
||||
items: inventoryItems,
|
||||
});
|
||||
}
|
||||
|
||||
// Vendor sections sorted alphabetically
|
||||
const sortedVendors = [...vendorMap.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [vendor, items] of sortedVendors) {
|
||||
sections.push({
|
||||
key: `vendor:${vendor}`,
|
||||
label: vendor,
|
||||
type: 'vendor',
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [visibleItems]);
|
||||
```
|
||||
|
||||
### Toggle Collapse Handler
|
||||
|
||||
```javascript
|
||||
const toggleSection = useCallback((sectionKey) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[sectionKey]: !prev[sectionKey],
|
||||
}));
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Section Header Rendering
|
||||
|
||||
```javascript
|
||||
// Within the render, for each section:
|
||||
{groupedSections.map((section) => {
|
||||
const isCollapsed = !!collapsedSections[section.key];
|
||||
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
onClick={() => toggleSection(section.key)}
|
||||
style={section.type === 'inventory'
|
||||
? STYLES.sectionHeaderInventory
|
||||
: STYLES.sectionHeaderVendor}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleSection(section.key);
|
||||
}
|
||||
}}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={`${section.label} section, ${section.items.length} items`}
|
||||
>
|
||||
{isCollapsed
|
||||
? <ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||
: <ChevronDown style={{ width: '14px', height: '14px' }} />
|
||||
}
|
||||
<span>{section.label}</span>
|
||||
<span style={STYLES.sectionCount}>({section.items.length})</span>
|
||||
</div>
|
||||
|
||||
{/* Section Body — only rendered when expanded */}
|
||||
{!isCollapsed && section.items.map((item) => (
|
||||
// ... existing queue item row rendering
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
New entries added to the existing `STYLES` constant:
|
||||
|
||||
```javascript
|
||||
sectionHeaderInventory: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginTop: '0.5rem',
|
||||
borderBottom: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#10B981',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
sectionHeaderVendor: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginTop: '0.5rem',
|
||||
borderBottom: '1px solid rgba(148, 163, 184, 0.15)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#94A3B8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
sectionCount: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
color: '#64748B',
|
||||
marginLeft: '0.25rem',
|
||||
},
|
||||
```
|
||||
|
||||
## Icon Imports
|
||||
|
||||
Add `ChevronDown` and `ChevronRight` to the existing lucide-react import:
|
||||
|
||||
```javascript
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
```
|
||||
|
||||
## Select All Interaction with Collapsed Sections
|
||||
|
||||
The existing `toggleSelectAll` and `allVisibleSelected` logic operates on the `visibleItems` array, which contains ALL pending items regardless of collapse state. This means:
|
||||
|
||||
- **Select All** selects/deselects all pending items across all sections, whether those sections are collapsed or expanded.
|
||||
- **Selection count** always reflects `selectedIds.size`, which includes items in collapsed sections.
|
||||
- **Floating action bar** operates on `selectedQueueItems` (derived from `selectedIds`), which is independent of collapse state.
|
||||
|
||||
No changes to the selection logic are needed. The collapse state is purely visual — it controls rendering, not data.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If `visibleItems` is empty, `groupedSections` will be an empty array, and the existing empty state renders instead.
|
||||
- If an item has an unexpected `workflow_type` (not CARD, GRANITE, DECOM, FP, or Archer), it falls into the vendor grouping path and is grouped by its vendor field. This is a safe fallback.
|
||||
- The `collapsedSections` state uses an object with string keys. Non-existent keys return `undefined`, which is falsy, so all sections default to expanded without explicit initialization.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- The `useMemo` grouping computation runs only when `visibleItems` changes (on fetch or status update). For typical queue sizes (10–100 items), this is negligible.
|
||||
- Collapse state changes trigger re-renders only of the affected section's body. React's reconciliation handles this efficiently since each section has a stable `key`.
|
||||
- No additional API calls or data fetching is introduced.
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: Grouping Correctness
|
||||
|
||||
For any array of visible queue items, every item with workflow_type CARD, GRANITE, or DECOM SHALL appear in the Inventory section, every item with workflow_type FP or Archer SHALL appear in the vendor section matching its vendor field (or "Unknown" if vendor is null/empty), and no item SHALL appear in more than one section.
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 1.5**
|
||||
|
||||
### Property 2: Section Ordering
|
||||
|
||||
For any non-empty grouping result, the Inventory section (if present) SHALL be the first element, and all subsequent vendor sections SHALL be sorted in case-insensitive alphabetical order by their label.
|
||||
|
||||
**Validates: Requirements 1.3, 1.4**
|
||||
|
||||
### Property 3: Empty Section Omission
|
||||
|
||||
For any array of visible queue items, the grouped output SHALL contain no sections with zero items. Specifically, if no CARD/GRANITE/DECOM items exist, no Inventory section appears; if no items exist for a given vendor, no section for that vendor appears.
|
||||
|
||||
**Validates: Requirements 1.6, 1.7**
|
||||
|
||||
### Property 4: Section Header Count Accuracy
|
||||
|
||||
For any section in the grouped output, the item count displayed in the section header SHALL equal the actual number of items in that section's items array.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2**
|
||||
|
||||
### Property 5: Selection Independence from Collapse State
|
||||
|
||||
For any combination of selected items and collapse state, the set of selected item IDs SHALL remain unchanged when sections are collapsed or expanded. The selection count SHALL always equal the total number of selected items across all sections, and Select All SHALL toggle selection for all visible items regardless of which sections are collapsed.
|
||||
|
||||
**Validates: Requirements 4.2, 4.4, 4.5**
|
||||
85
.kiro/specs/queue-collapsible-sections/requirements.md
Normal file
85
.kiro/specs/queue-collapsible-sections/requirements.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The IvantiTodoQueuePage currently displays all pending queue items in a flat table. This feature introduces collapsible sections that group items using a hybrid layout: an "Inventory" section at the top containing CARD, GRANITE, and DECOM workflow type items, followed by vendor-grouped sections for FP and Archer items. Each section header is clickable to collapse or expand its contents, allowing users to focus on relevant groups while maintaining full cross-section selection and existing page functionality.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Queue_Page**: The IvantiTodoQueuePage React component located at `frontend/src/components/pages/IvantiTodoQueuePage.js`
|
||||
- **Section**: A collapsible group of queue items sharing a common grouping criterion (either Inventory type or vendor name)
|
||||
- **Section_Header**: The clickable row that displays the section label, item count, and collapse/expand toggle
|
||||
- **Inventory_Section**: The top-level section containing items with workflow_type of CARD, GRANITE, or DECOM
|
||||
- **Vendor_Section**: A section grouping FP and Archer workflow type items by their vendor field
|
||||
- **Collapse_State**: A per-section boolean indicating whether the section body is hidden (collapsed) or visible (expanded)
|
||||
- **Visible_Items**: Queue items with status "pending" that are displayed in the main list
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Hybrid Grouping Layout
|
||||
|
||||
**User Story:** As a queue operator, I want items grouped into logical sections (Inventory at top, then vendor groups), so that I can quickly locate items by their workflow category.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Queue_Page SHALL group Visible_Items into an Inventory_Section containing all items where workflow_type is CARD, GRANITE, or DECOM.
|
||||
2. THE Queue_Page SHALL group remaining Visible_Items (workflow_type FP or Archer) into Vendor_Sections keyed by the item vendor field.
|
||||
3. THE Queue_Page SHALL display the Inventory_Section before all Vendor_Sections.
|
||||
4. THE Queue_Page SHALL sort Vendor_Sections alphabetically by vendor name.
|
||||
5. WHEN a Visible_Item has no vendor value, THE Queue_Page SHALL place the item in a Vendor_Section labeled "Unknown".
|
||||
6. WHEN the Inventory_Section contains zero items, THE Queue_Page SHALL omit the Inventory_Section from the layout.
|
||||
7. WHEN a vendor has zero pending items, THE Queue_Page SHALL omit that Vendor_Section from the layout.
|
||||
|
||||
### Requirement 2: Collapsible Section Headers
|
||||
|
||||
**User Story:** As a queue operator, I want to collapse sections I am not working on, so that I can reduce visual clutter and focus on relevant items.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Queue_Page SHALL render a Section_Header for each displayed Section.
|
||||
2. WHEN a user clicks a Section_Header, THE Queue_Page SHALL toggle the Collapse_State of that Section.
|
||||
3. WHILE a Section is in expanded Collapse_State, THE Queue_Page SHALL display all items belonging to that Section.
|
||||
4. WHILE a Section is in collapsed Collapse_State, THE Queue_Page SHALL hide all items belonging to that Section.
|
||||
5. WHILE a Section is in collapsed Collapse_State, THE Section_Header SHALL remain visible with the section label and item count.
|
||||
6. THE Section_Header SHALL display a directional chevron icon indicating the current Collapse_State (down for expanded, right for collapsed).
|
||||
7. THE Queue_Page SHALL initialize all Sections in expanded Collapse_State on page load.
|
||||
|
||||
### Requirement 3: Section Header Display
|
||||
|
||||
**User Story:** As a queue operator, I want section headers to show the group name and item count, so that I can assess workload distribution at a glance.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Section_Header for the Inventory_Section SHALL display the label "Inventory" with the total count of items in that section.
|
||||
2. THE Section_Header for each Vendor_Section SHALL display the vendor name with the total count of items in that section.
|
||||
3. THE Section_Header SHALL use a monospace font, uppercase text, and styling consistent with the dark theme tactical intelligence aesthetic.
|
||||
4. THE Section_Header SHALL use a distinct accent color for the Inventory_Section (green) and a neutral color for Vendor_Sections.
|
||||
5. THE Section_Header SHALL display a bottom border to visually separate the header from section content.
|
||||
|
||||
### Requirement 4: Cross-Section Selection Preservation
|
||||
|
||||
**User Story:** As a queue operator, I want to select items across multiple sections without losing selections when collapsing sections, so that I can create consolidated Jira tickets from items in different groups.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE selection mode is active, THE Queue_Page SHALL allow selecting items from any combination of Sections.
|
||||
2. WHEN a Section is collapsed, THE Queue_Page SHALL preserve the selected state of items within that collapsed Section.
|
||||
3. WHEN a Section is expanded after being collapsed, THE Queue_Page SHALL display the previously selected items as still selected.
|
||||
4. THE selection count indicator SHALL reflect the total count of selected items across all Sections regardless of Collapse_State.
|
||||
5. THE Select All checkbox SHALL toggle selection for all Visible_Items across all Sections regardless of Collapse_State.
|
||||
6. THE floating action bar SHALL operate on all selected items across all Sections regardless of Collapse_State.
|
||||
|
||||
### Requirement 5: Visual and Interaction Consistency
|
||||
|
||||
**User Story:** As a queue operator, I want the collapsible sections to match the existing page aesthetic and not break existing functionality, so that the experience remains cohesive.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Queue_Page SHALL use inline styles consistent with the existing STYLES constant and dark theme tactical intelligence aesthetic.
|
||||
2. THE Queue_Page SHALL use lucide-react icons for the collapse/expand chevron indicators.
|
||||
3. THE Section_Header SHALL provide a pointer cursor to indicate clickability.
|
||||
4. WHEN items are grouped into Sections, THE ticket link badges SHALL continue to display on items that have associated Jira tickets.
|
||||
5. WHEN items are grouped into Sections, THE consolidation modal SHALL continue to function with the selected items.
|
||||
6. WHEN items are grouped into Sections, THE floating action bar SHALL continue to appear when one or more items are selected.
|
||||
7. THE Queue_Page SHALL continue to display the completed items count at the bottom of the page.
|
||||
8. IF the queue contains zero Visible_Items, THEN THE Queue_Page SHALL display the existing empty state without any Section_Headers.
|
||||
112
.kiro/specs/queue-collapsible-sections/tasks.md
Normal file
112
.kiro/specs/queue-collapsible-sections/tasks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Implementation Plan: Queue Collapsible Sections
|
||||
|
||||
## Overview
|
||||
|
||||
Add collapsible, grouped sections to the IvantiTodoQueuePage. Items are organized into a hybrid layout: an "Inventory" section (CARD, GRANITE, DECOM workflow types) at the top, followed by vendor-grouped sections for FP and Archer items. Each section has a clickable header that toggles visibility. This is a frontend-only change to a single file with property-based tests for the grouping logic.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add section header styles and icon imports
|
||||
- [x] 1.1 Add ChevronDown and ChevronRight to the lucide-react import statement
|
||||
- Add `ChevronDown, ChevronRight` to the existing import from `lucide-react`
|
||||
- _Requirements: 5.2_
|
||||
|
||||
- [x] 1.2 Add section header style entries to the STYLES constant
|
||||
- Add `sectionHeaderInventory`, `sectionHeaderVendor`, and `sectionCount` style objects to the existing `STYLES` constant
|
||||
- Use green accent (`#10B981`) for Inventory header, neutral (`#94A3B8`) for vendor headers
|
||||
- Include `cursor: 'pointer'`, `userSelect: 'none'`, monospace font, uppercase text
|
||||
- _Requirements: 3.3, 3.4, 3.5, 5.1, 5.3_
|
||||
|
||||
- [x] 2. Implement grouping computation and collapse state
|
||||
- [x] 2.1 Add the `groupedSections` useMemo hook
|
||||
- Add a `useMemo` that transforms `visibleItems` into an array of section objects `{ key, label, type, items }`
|
||||
- Items with workflow_type CARD, GRANITE, or DECOM go into the Inventory section
|
||||
- Remaining items are grouped by vendor field (null/empty vendor → "Unknown")
|
||||
- Inventory section appears first (if non-empty), vendor sections sorted alphabetically
|
||||
- Sections with zero items are omitted
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
|
||||
|
||||
- [x] 2.2 Add collapse state and toggle handler
|
||||
- Add `const [collapsedSections, setCollapsedSections] = useState({})` for collapse tracking
|
||||
- Add `toggleSection` callback that flips the boolean for a given section key
|
||||
- All sections default to expanded (empty object → falsy lookup)
|
||||
- _Requirements: 2.2, 2.7_
|
||||
|
||||
- [x] 3. Refactor render to use grouped sections with collapsible headers
|
||||
- [x] 3.1 Replace flat `visibleItems.map(...)` with grouped section rendering
|
||||
- Map over `groupedSections` instead of `visibleItems` directly
|
||||
- For each section, render a clickable Section Header with chevron icon, label, and item count
|
||||
- Conditionally render section body (item rows) only when section is not collapsed
|
||||
- Preserve existing queue item row rendering logic inside each section body
|
||||
- Add `role="button"`, `tabIndex={0}`, `aria-expanded`, `aria-label` to section headers for accessibility
|
||||
- _Requirements: 2.1, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2_
|
||||
|
||||
- [x] 3.2 Verify cross-section selection still works correctly
|
||||
- Ensure Select All checkbox still toggles all `visibleItems` across all sections regardless of collapse state
|
||||
- Ensure selection count reflects total selected items across all sections
|
||||
- Ensure floating action bar operates on all selected items regardless of collapse state
|
||||
- Ensure collapsing a section does not clear or modify `selectedIds`
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 3.3 Verify existing functionality is preserved
|
||||
- Ticket link badges continue to display on items with associated Jira tickets
|
||||
- Consolidation modal continues to function with selected items
|
||||
- Floating action bar appears when items are selected
|
||||
- Completed items count still displays at the bottom
|
||||
- Empty state renders without section headers when no visible items exist
|
||||
- _Requirements: 5.4, 5.5, 5.6, 5.7, 5.8_
|
||||
|
||||
- [x] 4. Checkpoint - Ensure frontend builds successfully
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Write property-based tests for grouping logic
|
||||
- [x]* 5.1 Write property test: Grouping Correctness
|
||||
- **Property 1: Grouping Correctness**
|
||||
- Every item with workflow_type CARD, GRANITE, or DECOM appears in the Inventory section; every FP/Archer item appears in the vendor section matching its vendor field (or "Unknown" if null/empty); no item appears in more than one section
|
||||
- Extract the grouping logic into a testable pure function or test it inline
|
||||
- **Validates: Requirements 1.1, 1.2, 1.5**
|
||||
|
||||
- [x]* 5.2 Write property test: Section Ordering
|
||||
- **Property 2: Section Ordering**
|
||||
- Inventory section (if present) is always first; vendor sections are sorted alphabetically by label
|
||||
- **Validates: Requirements 1.3, 1.4**
|
||||
|
||||
- [x]* 5.3 Write property test: Empty Section Omission
|
||||
- **Property 3: Empty Section Omission**
|
||||
- No section in the output has zero items; if no inventory-type items exist, no Inventory section appears
|
||||
- **Validates: Requirements 1.6, 1.7**
|
||||
|
||||
- [x]* 5.4 Write property test: Section Header Count Accuracy
|
||||
- **Property 4: Section Header Count Accuracy**
|
||||
- For every section, the items array length equals the count that would be displayed in the header
|
||||
- **Validates: Requirements 3.1, 3.2**
|
||||
|
||||
- [x]* 5.5 Write property test: Selection Independence from Collapse State
|
||||
- **Property 5: Selection Independence from Collapse State**
|
||||
- Toggling collapse state does not alter the set of selected item IDs; Select All always covers all visible items regardless of collapse
|
||||
- **Validates: Requirements 4.2, 4.4, 4.5**
|
||||
|
||||
- [x] 6. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- This is a frontend-only change to a single file: `frontend/src/components/pages/IvantiTodoQueuePage.js`
|
||||
- Property tests should use `fast-check` (already a project dependency) and extract the grouping logic as a pure function for testability
|
||||
- The selection logic (`selectedIds`, `toggleSelectAll`, `allVisibleSelected`) operates on `visibleItems` which is independent of collapse state — no changes needed to selection logic
|
||||
- The collapse state is purely visual — it controls rendering, not data
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1", "1.2"] },
|
||||
{ "id": 1, "tasks": ["2.1", "2.2"] },
|
||||
{ "id": 2, "tasks": ["3.1"] },
|
||||
{ "id": 3, "tasks": ["3.2", "3.3"] },
|
||||
{ "id": 4, "tasks": ["5.1", "5.2", "5.3", "5.4", "5.5"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/remediation-plan-history/.config.kiro
Normal file
1
.kiro/specs/remediation-plan-history/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "731f6cf9-7d25-41ea-a550-745d6a917b3f", "workflowType": "requirements-first", "specType": "feature"}
|
||||
784
.kiro/specs/remediation-plan-history/design.md
Normal file
784
.kiro/specs/remediation-plan-history/design.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# Design Document: Remediation Plan History
|
||||
|
||||
## Overview
|
||||
|
||||
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new `compliance_item_history` table for historical entries. The pattern mirrors how `compliance_notes` works — separate rows with timestamps and attribution.
|
||||
|
||||
**Per-metric extension (Requirements 8–15):** The existing PATCH endpoint updates all active rows for a hostname uniformly. Since `compliance_items` already stores `resolution_date` and `remediation_plan` per row (each row is a hostname+metric_id pair), the extension allows targeting specific metrics via optional `metric_id`/`metric_ids` parameters. This mirrors the multi-metric notes pattern established in the `compliance-multi-metric-notes` spec — same chip selector UI, same Select All toggle, same `metric_ids` array API convention.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ComplianceDetailPanel │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
||||
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
|
||||
│ │ Date Input │ │ Plan Input │ │ │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┴──────────────────────┘ │
|
||||
│ │ │
|
||||
│ PATCH /metadata │
|
||||
│ { resolution_date, remediation_plan, │
|
||||
│ change_reason } │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Change History Section │ │
|
||||
│ │ [date] [field] [old→new] [by user] [reason] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend (compliance.js) │
|
||||
│ │
|
||||
│ PATCH /items/:hostname/metadata │
|
||||
│ 1. Validate inputs │
|
||||
│ 2. SELECT current values from compliance_items │
|
||||
│ 3. Compare old vs new — skip if identical │
|
||||
│ 4. INSERT into compliance_item_history (per changed field) │
|
||||
│ 5. UPDATE compliance_items with new values │
|
||||
│ │
|
||||
│ GET /items/:hostname │
|
||||
│ (existing) + SELECT from compliance_item_history │
|
||||
│ LIMIT 10 ORDER BY changed_at DESC │
|
||||
│ │
|
||||
│ POST /vcl/bulk-commit │
|
||||
│ For each hostname: same compare-then-insert pattern │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ │
|
||||
│ compliance_items (unchanged) │
|
||||
│ resolution_date DATE │
|
||||
│ remediation_plan TEXT │
|
||||
│ │
|
||||
│ compliance_item_history (new) │
|
||||
│ id, hostname, field_name, old_value, new_value, │
|
||||
│ change_reason, changed_by, changed_at │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend Components
|
||||
|
||||
- **PATCH /api/compliance/items/:hostname/metadata** — updates resolution_date and/or remediation_plan on compliance_items rows, records field-level change history. Extended with optional `metric_id`/`metric_ids` for per-metric scoping.
|
||||
- **GET /api/compliance/items/:hostname** — returns device detail including history entries (with metric_id field).
|
||||
- **POST /api/compliance/vcl/bulk-commit** — bulk update path that also records history per hostname.
|
||||
|
||||
### Frontend Components
|
||||
|
||||
- **ComplianceDetailPanel.js** — slide-out panel displaying device compliance detail, metadata editing, change history, and notes.
|
||||
- **MetricChipSelector (metadata)** — chip-based multi-select for choosing which metrics a resolution_date/remediation_plan update applies to. Positioned above the metadata inputs.
|
||||
- **HistoryMetricLabel** — renders a MetricChip for per-metric history entries or "All metrics" label for hostname-level entries.
|
||||
|
||||
## Data Models
|
||||
|
||||
### New Table: compliance_item_history
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
change_reason TEXT,
|
||||
changed_by TEXT NOT NULL,
|
||||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||
ON compliance_item_history(hostname, field_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||
ON compliance_item_history(changed_at);
|
||||
```
|
||||
|
||||
### Existing Table: compliance_items (no changes)
|
||||
|
||||
The `resolution_date` and `remediation_plan` columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
|
||||
|
||||
## API Changes
|
||||
|
||||
### PATCH /api/compliance/items/:hostname/metadata
|
||||
|
||||
**Request body changes:**
|
||||
|
||||
```json
|
||||
{
|
||||
"resolution_date": "2026-03-15",
|
||||
"remediation_plan": "Upgrade firmware to v4.2",
|
||||
"change_reason": "Vendor pushed back delivery date"
|
||||
}
|
||||
```
|
||||
|
||||
New optional field: `change_reason` (string, max 500 characters, nullable).
|
||||
|
||||
**Behavior changes:**
|
||||
|
||||
1. Before updating compliance_items, query the current values:
|
||||
```sql
|
||||
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items
|
||||
WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
2. For each field being updated, compare old vs new. If different, insert a history row:
|
||||
```sql
|
||||
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
```
|
||||
|
||||
3. Then proceed with the existing UPDATE as before.
|
||||
|
||||
**Response:** unchanged (`{ updated: number }`).
|
||||
|
||||
### GET /api/compliance/items/:hostname
|
||||
|
||||
**Response changes:**
|
||||
|
||||
Add a `history` array to the response:
|
||||
|
||||
```json
|
||||
{
|
||||
"hostname": "server01.example.com",
|
||||
"ip_address": "10.0.1.5",
|
||||
"device_type": "Server",
|
||||
"team": "STEAM",
|
||||
"resolution_date": "2026-03-15",
|
||||
"remediation_plan": "Upgrade firmware to v4.2",
|
||||
"metrics": [...],
|
||||
"notes": [...],
|
||||
"history": [
|
||||
{
|
||||
"id": 42,
|
||||
"field_name": "resolution_date",
|
||||
"old_value": "2026-02-01",
|
||||
"new_value": "2026-03-15",
|
||||
"change_reason": "Vendor pushed back delivery date",
|
||||
"changed_by": "jsmith",
|
||||
"changed_at": "2026-01-20T14:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"field_name": "remediation_plan",
|
||||
"old_value": null,
|
||||
"new_value": "Upgrade firmware to v4.2",
|
||||
"change_reason": null,
|
||||
"changed_by": "jsmith",
|
||||
"changed_at": "2026-01-15T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
```sql
|
||||
SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
FROM compliance_item_history
|
||||
WHERE hostname = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
### POST /api/compliance/vcl/bulk-commit
|
||||
|
||||
**Behavior changes:**
|
||||
|
||||
Within the transaction, before updating each hostname:
|
||||
1. Query current values for that hostname
|
||||
2. Compare each field (resolution_date, remediation_plan) against the incoming value
|
||||
3. Insert history rows for changed fields with `changed_by` set to `req.user.username`
|
||||
|
||||
No request/response shape changes. The `change_reason` is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### ComplianceDetailPanel.js
|
||||
|
||||
**New state:**
|
||||
- `changeReason` (string) — text input value for the reason field
|
||||
- `history` (array) — populated from the API response
|
||||
|
||||
**New UI elements:**
|
||||
|
||||
1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
|
||||
|
||||
2. **Change History section** — a new `<Section>` component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
|
||||
|
||||
**History entry display format:**
|
||||
```
|
||||
[field_name icon] old_value → new_value
|
||||
username · 2026-01-20 [reason if present]
|
||||
```
|
||||
|
||||
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
|
||||
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
|
||||
- Change reason: displayed in muted text below the change line when present
|
||||
|
||||
**Save flow update:**
|
||||
- The `handleSaveMetadata` function passes `change_reason` alongside the field values
|
||||
- After successful save, clear the `changeReason` input and re-fetch detail (which now includes updated history)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
|
||||
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
|
||||
- `change_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
|
||||
|
||||
## Migration
|
||||
|
||||
File: `backend/migrations/add_compliance_item_history.js`
|
||||
|
||||
```javascript
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting compliance_item_history migration...');
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
change_reason TEXT,
|
||||
changed_by TEXT NOT NULL,
|
||||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ compliance_item_history table created (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||
ON compliance_item_history(hostname, field_name)
|
||||
`);
|
||||
console.log('✓ hostname/field_name index created');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||
ON compliance_item_history(changed_at)
|
||||
`);
|
||||
console.log('✓ changed_at index created');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
```
|
||||
|
||||
## Reporting Isolation
|
||||
|
||||
No changes to VCL reporting queries. The following queries continue to read directly from `compliance_items.resolution_date`:
|
||||
|
||||
- Donut chart: `SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname`
|
||||
- Burndown forecast: `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL`
|
||||
- Per-vertical burndown in vclMultiVertical.js
|
||||
|
||||
The `compliance_item_history` table is never referenced by any reporting query.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
|
||||
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
|
||||
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
|
||||
- No additional queries are added to the VCL reporting paths.
|
||||
|
||||
---
|
||||
|
||||
## Per-Metric Extension (Requirements 8–15)
|
||||
|
||||
### Overview
|
||||
|
||||
The `compliance_items` table already stores `resolution_date` and `remediation_plan` per row, where each row represents a unique (hostname, metric_id) pair. The current PATCH endpoint updates ALL active rows for a hostname uniformly. This extension adds optional metric scoping so analysts can set different resolution dates and remediation plans for individual metrics on the same device.
|
||||
|
||||
The UI pattern replicates the multi-metric notes selector from the `compliance-multi-metric-notes` spec: chip-based multi-select with Select All toggle, positioned above the resolution_date and remediation_plan inputs.
|
||||
|
||||
### Architecture — Per-Metric Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Panel as ComplianceDetailPanel
|
||||
participant API as PATCH /items/:hostname/metadata
|
||||
participant DB as PostgreSQL
|
||||
|
||||
User->>Panel: Open detail panel for hostname
|
||||
Panel->>Panel: Pre-select all active metrics (Select All default)
|
||||
User->>Panel: Deselect some metrics, select specific ones
|
||||
Panel->>Panel: Compute shared values for selected metrics
|
||||
Panel->>Panel: Display shared value or "Multiple values" placeholder
|
||||
|
||||
User->>Panel: Edit resolution_date / remediation_plan
|
||||
User->>Panel: Click Save
|
||||
|
||||
alt All metrics selected (Select All)
|
||||
Panel->>API: PATCH { resolution_date, remediation_plan, change_reason }
|
||||
Note over Panel,API: No metric_ids → hostname-level update (backward compat)
|
||||
else Specific metrics selected
|
||||
Panel->>API: PATCH { resolution_date, remediation_plan, change_reason, metric_ids: [...] }
|
||||
end
|
||||
|
||||
API->>API: Validate metric_ids against active items
|
||||
API->>DB: SELECT current values per targeted metric
|
||||
loop For each targeted metric with changed values
|
||||
API->>DB: INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, ...)
|
||||
end
|
||||
API->>DB: UPDATE compliance_items SET ... WHERE hostname = $1 AND metric_id = ANY($2)
|
||||
API-->>Panel: { updated: N }
|
||||
Panel->>Panel: Re-fetch detail, refresh history
|
||||
```
|
||||
|
||||
### Schema Extension
|
||||
|
||||
#### Add metric_id column to compliance_item_history
|
||||
|
||||
```sql
|
||||
ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||||
ON compliance_item_history(hostname, metric_id);
|
||||
```
|
||||
|
||||
The column is nullable. Existing rows retain `NULL` metric_id, indicating they were hostname-level changes made before this extension. New per-metric changes populate the column; hostname-level changes (no metric_ids in request) continue to insert with `NULL`.
|
||||
|
||||
#### Migration File: `backend/migrations/add_compliance_history_metric_id.js`
|
||||
|
||||
```javascript
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting compliance_item_history metric_id migration...');
|
||||
try {
|
||||
// Add nullable metric_id column (idempotent check)
|
||||
const { rows } = await pool.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'compliance_item_history' AND column_name = 'metric_id'
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
await pool.query(`ALTER TABLE compliance_item_history ADD COLUMN metric_id TEXT`);
|
||||
console.log('✓ metric_id column added to compliance_item_history');
|
||||
} else {
|
||||
console.log('✓ metric_id column already exists');
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||||
ON compliance_item_history(hostname, metric_id)
|
||||
`);
|
||||
console.log('✓ (hostname, metric_id) index created');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
```
|
||||
|
||||
No backfill is performed — pre-existing history rows with `NULL` metric_id correctly represent hostname-level changes.
|
||||
|
||||
### API Changes — Per-Metric Scoping
|
||||
|
||||
#### PATCH /api/compliance/items/:hostname/metadata (extended)
|
||||
|
||||
**New optional request body fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"resolution_date": "2026-03-15",
|
||||
"remediation_plan": "Upgrade firmware to v4.2",
|
||||
"change_reason": "Vendor pushed back delivery date",
|
||||
"metric_id": "2.1.1",
|
||||
"metric_ids": ["2.1.1", "2.3.2"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `metric_id` | string (optional) | Scope update to a single metric |
|
||||
| `metric_ids` | string[] (optional) | Scope update to multiple specific metrics |
|
||||
|
||||
**Precedence:** If both `metric_id` and `metric_ids` are provided, `metric_ids` wins and `metric_id` is ignored.
|
||||
|
||||
**Validation:**
|
||||
- Each metric_id must be a non-empty string of 100 characters or fewer
|
||||
- Each metric_id must correspond to an active compliance_item for the hostname — if any provided metric_id has no matching active row, return 400 with `{ error: "Invalid metric_id: <value> — no active compliance item found" }`
|
||||
|
||||
**Behavior by scoping mode:**
|
||||
|
||||
| Mode | Condition | Query target | History metric_id |
|
||||
|---|---|---|---|
|
||||
| Hostname-level | Neither `metric_id` nor `metric_ids` provided | All active rows for hostname | `NULL` |
|
||||
| Single metric | `metric_id` provided (no `metric_ids`) | Single row matching hostname + metric_id | The metric_id value |
|
||||
| Multi-metric | `metric_ids` provided | Rows matching hostname + any of the metric_ids | Per-row metric_id |
|
||||
|
||||
**Per-metric history insertion logic:**
|
||||
|
||||
When metric scoping is active, the endpoint queries current values per targeted metric individually (since they may differ), then inserts one history row per metric per changed field:
|
||||
|
||||
```sql
|
||||
-- Get current values for each targeted metric
|
||||
SELECT metric_id, resolution_date, remediation_plan
|
||||
FROM compliance_items
|
||||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Insert history per metric per changed field
|
||||
INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Update only targeted rows
|
||||
UPDATE compliance_items
|
||||
SET resolution_date = $1, remediation_plan = $2
|
||||
WHERE hostname = $3 AND metric_id = ANY($4) AND status = 'active'
|
||||
```
|
||||
|
||||
**Response:** unchanged (`{ updated: number }`).
|
||||
|
||||
#### GET /api/compliance/items/:hostname (extended history response)
|
||||
|
||||
The history query now includes `metric_id`:
|
||||
|
||||
```sql
|
||||
SELECT id, hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
FROM compliance_item_history
|
||||
WHERE hostname = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
Response shape — history entries now include `metric_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"id": 55,
|
||||
"metric_id": "2.1.1",
|
||||
"field_name": "resolution_date",
|
||||
"old_value": "2026-02-01",
|
||||
"new_value": "2026-04-15",
|
||||
"change_reason": "Vendor delay on patch",
|
||||
"changed_by": "jsmith",
|
||||
"changed_at": "2026-02-10T11:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"metric_id": null,
|
||||
"field_name": "remediation_plan",
|
||||
"old_value": "Upgrade firmware",
|
||||
"new_value": "Replace hardware",
|
||||
"change_reason": null,
|
||||
"changed_by": "admin",
|
||||
"changed_at": "2026-02-05T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Changes — Per-Metric Metadata Selector
|
||||
|
||||
#### New State
|
||||
|
||||
```javascript
|
||||
const [metricSelection, setMetricSelection] = useState([]); // metric_ids for metadata editing
|
||||
```
|
||||
|
||||
This is separate from the existing `selectedMetrics` state (used for notes). The metadata metric selector has its own selection state.
|
||||
|
||||
#### UI Layout
|
||||
|
||||
The Metric_Selector is placed above the resolution_date and remediation_plan inputs, matching the notes section pattern:
|
||||
|
||||
```
|
||||
ComplianceDetailPanel
|
||||
├── Header (hostname, IP, device type, team)
|
||||
├── Section: Failing Metrics
|
||||
├── Section: Resolved Metrics
|
||||
├── Section: Remediation Plan & Resolution Date
|
||||
│ ├── MetricChipSelector (for metadata) ← NEW
|
||||
│ │ ├── Select All / Deselect All toggle
|
||||
│ │ └── MetricChip (per active metric, clickable)
|
||||
│ ├── Resolution Date input (populated from selection)
|
||||
│ ├── Remediation Plan textarea (populated from selection)
|
||||
│ ├── Change Reason input
|
||||
│ └── Save button
|
||||
├── Section: Change History
|
||||
│ └── HistoryEntry (with optional MetricChip) ← EXTENDED
|
||||
├── Section: Notes
|
||||
│ ├── NoteCard (grouped by group_id)
|
||||
│ └── Add Note Form (with its own MetricChipSelector)
|
||||
```
|
||||
|
||||
#### MetricChipSelector Behavior (Metadata)
|
||||
|
||||
| State | Behavior |
|
||||
|---|---|
|
||||
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
|
||||
| 2+ active metrics, panel just opened | **All metrics pre-selected** (Select All default). Select All toggle visible. |
|
||||
| User clicks unselected chip | Chip added to selection |
|
||||
| User clicks selected chip (2+ selected) | Chip removed from selection |
|
||||
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
|
||||
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
|
||||
| Deselect All clicked | All metrics deselected except the first (minimum selection invariant) |
|
||||
|
||||
**Key difference from notes selector:** The metadata selector defaults to "Select All" on panel open (preserving existing hostname-level behavior), while the notes selector defaults to the first metric only.
|
||||
|
||||
#### Per-Metric Field Display Logic
|
||||
|
||||
When the metric selection changes, the panel computes what to display in the resolution_date and remediation_plan inputs:
|
||||
|
||||
```javascript
|
||||
function computeSharedValues(metrics, selectedIds) {
|
||||
const selected = metrics.filter(m => selectedIds.includes(m.metric_id));
|
||||
if (selected.length === 0) return { resolution_date: '', remediation_plan: '' };
|
||||
if (selected.length === 1) {
|
||||
return {
|
||||
resolution_date: selected[0].resolution_date || '',
|
||||
remediation_plan: selected[0].remediation_plan || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple selected — check if values are uniform
|
||||
const dates = new Set(selected.map(m => m.resolution_date || ''));
|
||||
const plans = new Set(selected.map(m => m.remediation_plan || ''));
|
||||
|
||||
return {
|
||||
resolution_date: dates.size === 1 ? [...dates][0] : null, // null = "Multiple values"
|
||||
remediation_plan: plans.size === 1 ? [...plans][0] : null, // null = "Multiple values"
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Display rules:**
|
||||
|
||||
| Computed value | Input state |
|
||||
|---|---|
|
||||
| Non-null string (including empty) | Input populated with that value |
|
||||
| `null` (multiple different values) | Input empty, placeholder text: "Multiple values" |
|
||||
|
||||
**Save behavior with "Multiple values":**
|
||||
|
||||
- If the user leaves an input untouched while it shows "Multiple values" placeholder, that field is NOT included in the PATCH request body — preserving existing per-metric values.
|
||||
- If the user types a new value into a "Multiple values" field, that value is sent and applied to all selected metrics.
|
||||
|
||||
#### Save Flow — Metric Scoping
|
||||
|
||||
```javascript
|
||||
const handleSaveMetadata = async (fields) => {
|
||||
const body = { ...fields };
|
||||
if (changeReason.trim()) body.change_reason = changeReason.trim();
|
||||
|
||||
// Determine if we need metric scoping
|
||||
const allSelected = activeMetrics.length > 0 &&
|
||||
activeMetrics.every(m => metricSelection.includes(m.metric_id));
|
||||
|
||||
if (!allSelected) {
|
||||
// Specific metrics selected — include metric_ids
|
||||
body.metric_ids = metricSelection;
|
||||
}
|
||||
// If all selected, omit metric_ids → hostname-level update (backward compat)
|
||||
|
||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// ... error handling, re-fetch ...
|
||||
};
|
||||
```
|
||||
|
||||
### History Display — Per-Metric Extension
|
||||
|
||||
History entries now optionally show which metric the change applied to:
|
||||
|
||||
**Display format (extended):**
|
||||
|
||||
```
|
||||
[MetricChip "2.1.1"] [field_name icon] old_value → new_value
|
||||
username · 2026-01-20 [reason if present]
|
||||
```
|
||||
|
||||
Or for hostname-level changes (metric_id is null):
|
||||
|
||||
```
|
||||
[All metrics] [field_name icon] old_value → new_value
|
||||
username · 2026-01-20 [reason if present]
|
||||
```
|
||||
|
||||
**Rendering logic:**
|
||||
|
||||
```javascript
|
||||
function HistoryMetricLabel({ metricId, metricMap }) {
|
||||
if (metricId) {
|
||||
return <MetricChip metricId={metricId} category={metricMap[metricId] || ''} />;
|
||||
}
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic',
|
||||
padding: '0.15rem 0.4rem',
|
||||
background: 'rgba(100,116,139,0.1)',
|
||||
borderRadius: '0.2rem',
|
||||
}}>
|
||||
All metrics
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Per-Metric Reporting
|
||||
|
||||
The burndown and donut queries already read from individual `compliance_items` rows (each row has its own `resolution_date`). The per-metric extension does not change reporting queries — it only changes how the PATCH endpoint targets rows.
|
||||
|
||||
**Existing burndown query (unchanged):**
|
||||
|
||||
```sql
|
||||
SELECT resolution_date, COUNT(*) as count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active' AND resolution_date IS NOT NULL
|
||||
GROUP BY resolution_date
|
||||
ORDER BY resolution_date
|
||||
```
|
||||
|
||||
Each row is already a (hostname, metric_id) pair, so per-metric resolution dates are naturally reflected in the burndown without query changes.
|
||||
|
||||
**Aggregated hostname view (unchanged):**
|
||||
|
||||
```sql
|
||||
SELECT hostname, MAX(resolution_date) as resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY hostname
|
||||
```
|
||||
|
||||
When deduplicating by hostname for summary views, the latest (MAX) resolution_date among all active metrics is used.
|
||||
|
||||
### Error Handling — Per-Metric Extension
|
||||
|
||||
| Scenario | Response | Behavior |
|
||||
|---|---|---|
|
||||
| `metric_ids` contains a metric_id with no active row | 400 `{ error: "Invalid metric_id: <value> — no active compliance item found" }` | No rows updated, no history inserted |
|
||||
| `metric_ids` is empty array | 400 `{ error: "metric_ids must contain at least one entry" }` | No rows updated |
|
||||
| `metric_id` or entry in `metric_ids` exceeds 100 chars | 400 `{ error: "metric_id exceeds 100 characters" }` | No rows updated |
|
||||
| `metric_id` or entry in `metric_ids` is empty string | 400 `{ error: "metric_id cannot be empty" }` | No rows updated |
|
||||
| Per-metric update targets metrics with different current values | N/A (success) | One history entry per metric per changed field |
|
||||
| All targeted metrics already have the new value | 200 `{ updated: 0 }` | No history entries created (no-change skip) |
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The per-metric extension is fully backward compatible:
|
||||
|
||||
1. **API:** Requests without `metric_id`/`metric_ids` follow the existing hostname-level path — update all active rows, insert history with `NULL` metric_id.
|
||||
2. **Bulk commit:** Continues to apply values to all active metrics for each hostname. No `metric_ids` support in bulk (same as before).
|
||||
3. **UI default:** Panel opens with all metrics selected. Saving without changing selection omits `metric_ids` from the request, triggering hostname-level behavior.
|
||||
4. **History display:** Existing history entries (NULL metric_id) display as "All metrics" — no visual regression.
|
||||
5. **Reporting:** No query changes. Per-row resolution_date was already the source of truth for burndown calculations.
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
### Property 1: History entries are created only when values change
|
||||
|
||||
*For any* PATCH request to `/items/:hostname/metadata` with a resolution_date or remediation_plan value, a history entry is inserted if and only if the new value differs from the current value on the targeted compliance_items row(s). Identical values produce zero history rows.
|
||||
|
||||
**Validates: Requirements 1.4, 6.3**
|
||||
|
||||
### Property 2: Per-metric history records the correct old_value per row
|
||||
|
||||
*For any* per-metric update targeting N metrics with potentially different current values, the system inserts one history entry per metric per changed field, and each entry's `old_value` matches the actual previous value of that specific compliance_items row — not a shared/aggregated value.
|
||||
|
||||
**Validates: Requirements 11.4, 11.5**
|
||||
|
||||
### Property 3: Hostname-level updates produce NULL metric_id in history
|
||||
|
||||
*For any* PATCH request without `metric_id` or `metric_ids`, all resulting history entries have `metric_id = NULL`, indicating the change applied to all active metrics for the hostname.
|
||||
|
||||
**Validates: Requirements 11.3, 15.1**
|
||||
|
||||
### Property 4: Backward compatibility — omitting metric_ids updates all active rows
|
||||
|
||||
*For any* PATCH request without `metric_id` or `metric_ids`, the number of compliance_items rows updated equals the count of active rows for that hostname. The behavior is identical to the pre-extension endpoint.
|
||||
|
||||
**Validates: Requirements 8.6, 15.1, 15.2**
|
||||
|
||||
### Property 5: Invalid metric_ids reject the entire request atomically
|
||||
|
||||
*For any* PATCH request where at least one entry in `metric_ids` does not correspond to an active compliance_item for the hostname, the endpoint returns 400 and no rows are updated, no history entries are created.
|
||||
|
||||
**Validates: Requirements 8.7**
|
||||
|
||||
### Property 6: Select All default preserves hostname-level behavior
|
||||
|
||||
*For any* panel open action on a hostname with N active metrics, the metadata metric selector defaults to all N metrics selected. Saving without changing the selection omits `metric_ids` from the request body, triggering the hostname-level update path.
|
||||
|
||||
**Validates: Requirements 9.7, 15.3, 15.4**
|
||||
|
||||
### Property 7: Shared value display is a pure function of selection
|
||||
|
||||
*For any* set of selected metrics, if all share the same resolution_date (or remediation_plan), the input displays that value. If any differ, the input shows "Multiple values" placeholder. This is deterministic and independent of selection order.
|
||||
|
||||
**Validates: Requirements 10.2, 10.3, 10.4, 10.5**
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Backend:**
|
||||
- PATCH without metric_ids updates all active rows (backward compat)
|
||||
- PATCH with `metric_id` updates only the single matching row
|
||||
- PATCH with `metric_ids` updates only matching rows
|
||||
- Invalid metric_id returns 400 with descriptive error
|
||||
- History entries include correct metric_id (or NULL for hostname-level)
|
||||
- Per-metric update with different current values creates per-row history entries
|
||||
- Both `metric_id` and `metric_ids` provided — `metric_ids` wins
|
||||
|
||||
**Frontend:**
|
||||
- MetricChipSelector defaults to all metrics selected on panel open
|
||||
- Shared value computation returns correct value when all metrics agree
|
||||
- Shared value computation returns null when metrics disagree
|
||||
- Save with all selected omits metric_ids from request body
|
||||
- Save with subset includes metric_ids in request body
|
||||
- History entries with metric_id render MetricChip
|
||||
- History entries with null metric_id render "All metrics" label
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests use `fast-check` with a minimum of 100 iterations per property.
|
||||
|
||||
- **Property 2: Per-metric history records correct old_value** — generate random sets of metrics with varying current values, apply a uniform update, verify each history entry's old_value matches the pre-update value for that specific metric.
|
||||
- **Property 4: Backward compatibility** — generate random hostnames with N active metrics, send PATCH without metric_ids, verify all N rows are updated.
|
||||
- **Property 7: Shared value display** — generate random arrays of metric objects with varying resolution_date/remediation_plan values, verify computeSharedValues returns the correct shared value or null.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end: open panel → select 2 of 4 metrics → change resolution_date → save → verify only 2 rows updated, history entries have correct metric_ids
|
||||
- Migration: verify metric_id column exists after running migration, existing rows retain NULL
|
||||
- Backward compat: existing bulk-commit flow continues to work without metric_ids
|
||||
|
||||
### Performance Considerations — Per-Metric Extension
|
||||
|
||||
- Per-metric updates query current values with `WHERE metric_id = ANY($2)` — the new `(hostname, metric_id)` index on `compliance_item_history` ensures efficient lookups.
|
||||
- Worst case: a device with 15 active metrics, all selected, both fields changed = 30 history inserts per save. Still lightweight within a single transaction.
|
||||
- The `(hostname, metric_id)` index on the history table supports future filtering of history by metric if needed.
|
||||
- No additional queries added to reporting paths.
|
||||
202
.kiro/specs/remediation-plan-history/requirements.md
Normal file
202
.kiro/specs/remediation-plan-history/requirements.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Historical tracking for resolution dates and remediation plans on compliance items. When a user changes the resolution_date or remediation_plan for a device, the previous value is preserved as an audit trail entry with a timestamp and the identity of the user who made the change. The most recent values remain directly queryable on the compliance_items table so existing VCL reporting queries continue to work without modification.
|
||||
|
||||
This spec also covers per-metric scoping of resolution_date and remediation_plan (GitLab issue #19). Previously these fields were edited at the hostname level (one value applied uniformly to all active metrics for a device). The per-metric extension allows analysts to set different resolution dates and remediation plans for individual metrics within the same device, matching the pattern already established by compliance notes.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Compliance_Item**: A row in the `compliance_items` table representing a single non-compliant device/metric pair.
|
||||
- **Resolution_Date**: A DATE field on a compliance item indicating when remediation is expected to complete.
|
||||
- **Remediation_Plan**: A TEXT field (max 2000 characters) describing the planned remediation approach.
|
||||
- **History_Entry**: A row in the `compliance_item_history` table capturing a previous value of resolution_date or remediation_plan before it was overwritten.
|
||||
- **Change_Reason**: An optional text field on a History_Entry describing why the change was made.
|
||||
- **Detail_Panel**: The ComplianceDetailPanel UI component that displays device-level compliance information and allows editing of metadata fields.
|
||||
- **VCL_Report**: The multi-vertical compliance reporting system that uses resolution_date for burndown forecasts and blocked/in-progress donut charts.
|
||||
- **Current_Value**: The value stored directly on the compliance_items row, representing the most recent resolution_date or remediation_plan.
|
||||
- **Metric_Selector**: The UI control in the Detail_Panel that allows the user to choose which metric(s) a remediation plan update applies to. Displays active metrics as selectable options with category-colored chips.
|
||||
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — a metric currently failing for that device.
|
||||
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
|
||||
- **Metadata_API**: The `PATCH /api/compliance/items/:hostname/metadata` endpoint that updates resolution_date and remediation_plan on compliance items.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Persist History on Field Change
|
||||
|
||||
**User Story:** As a compliance analyst, I want previous resolution dates and remediation plans to be preserved when I make changes, so that I have an audit trail of what was planned and when plans changed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user updates the resolution_date for a hostname via the metadata PATCH endpoint, THE History_Service SHALL insert a History_Entry containing the previous resolution_date value, the field name, the hostname, the timestamp of the change, the username of the user who made the change, and the change_reason if provided.
|
||||
2. WHEN a user updates the remediation_plan for a hostname via the metadata PATCH endpoint, THE History_Service SHALL insert a History_Entry containing the previous remediation_plan value, the field name, the hostname, the timestamp of the change, the username of the user who made the change, and the change_reason if provided.
|
||||
3. WHEN the previous value is NULL and the user sets a new value, THE History_Service SHALL insert a History_Entry with the old_value recorded as NULL.
|
||||
4. WHEN the new value is identical to the current value, THE History_Service SHALL NOT create a History_Entry.
|
||||
5. WHEN a bulk update changes resolution_date or remediation_plan for multiple hostnames, THE History_Service SHALL insert one History_Entry per hostname per changed field.
|
||||
6. THE History_Service SHALL accept an optional change_reason field in the metadata PATCH request body.
|
||||
|
||||
### Requirement 2: History Storage Schema
|
||||
|
||||
**User Story:** As a system administrator, I want history entries stored in a dedicated table with proper indexing, so that history queries are fast and do not impact existing compliance_items queries.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Database SHALL store history entries in a `compliance_item_history` table with columns: id (serial primary key), hostname (text, not null), field_name (text, not null), old_value (text), new_value (text), change_reason (text), changed_by (text, not null), changed_at (timestamptz, default NOW()).
|
||||
2. THE Database SHALL index the `compliance_item_history` table on (hostname, field_name) for efficient per-device history lookups.
|
||||
3. THE Database SHALL index the `compliance_item_history` table on (changed_at) for chronological queries.
|
||||
4. THE compliance_items table SHALL continue to store the current resolution_date and remediation_plan directly as columns, unchanged from the existing schema.
|
||||
|
||||
### Requirement 3: Reporting Isolation
|
||||
|
||||
**User Story:** As a VCL report consumer, I want burndown forecasts and donut charts to use only the current resolution_date, so that historical changes do not cause double-counting or incorrect projections.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE VCL_Report SHALL read resolution_date exclusively from the compliance_items table for burndown and donut calculations.
|
||||
2. THE VCL_Report SHALL NOT join or reference the compliance_item_history table for any reporting query.
|
||||
3. WHEN multiple History_Entries exist for a hostname, THE VCL_Report SHALL use only the Current_Value from compliance_items.resolution_date for forecasting.
|
||||
|
||||
### Requirement 4: History Retrieval API
|
||||
|
||||
**User Story:** As a compliance analyst, I want to retrieve the change history for a device's resolution date and remediation plan, so that I can see who changed what and when.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a client requests the detail for a hostname, THE Compliance_API SHALL return the history of resolution_date and remediation_plan changes alongside the current values and notes.
|
||||
2. THE Compliance_API SHALL return history entries sorted by changed_at in descending order (most recent first).
|
||||
3. THE Compliance_API SHALL return a maximum of 10 history entries per hostname.
|
||||
4. THE Compliance_API SHALL include the fields: field_name, old_value, new_value, change_reason, changed_by, and changed_at for each History_Entry.
|
||||
5. IF no history entries exist for a hostname, THE Compliance_API SHALL return an empty array for the history field.
|
||||
|
||||
### Requirement 5: History Display in Detail Panel
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see the history of changes to resolution date and remediation plan in the device detail panel, so that I can understand how plans have evolved over time.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Detail_Panel SHALL display a "Change History" section showing all History_Entries for the selected hostname.
|
||||
2. WHEN a History_Entry exists, THE Detail_Panel SHALL display the field that changed, the old value, the new value, who made the change, when the change occurred, and the change reason if one was provided.
|
||||
3. THE Detail_Panel SHALL display history entries in reverse chronological order (most recent change at the top).
|
||||
4. WHEN no history entries exist, THE Detail_Panel SHALL display a message indicating no changes have been recorded.
|
||||
5. THE Detail_Panel SHALL format resolution_date values as YYYY-MM-DD and remediation_plan values as truncated text with a tooltip or expandable view for long entries.
|
||||
6. THE Detail_Panel SHALL include a text input for change_reason when saving resolution_date or remediation_plan changes.
|
||||
|
||||
### Requirement 6: Bulk Update History Tracking
|
||||
|
||||
**User Story:** As a compliance analyst, I want bulk xlsx updates to also track history, so that mass changes to resolution dates and remediation plans are auditable.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the bulk update commit endpoint applies changes to resolution_date or remediation_plan, THE History_Service SHALL create History_Entries for each hostname where the value changed.
|
||||
2. THE History_Service SHALL record the changed_by as the username of the user who initiated the bulk update.
|
||||
3. WHEN a bulk update row contains the same value as the current value for a hostname, THE History_Service SHALL NOT create a History_Entry for that field.
|
||||
|
||||
### Requirement 7: Database Migration
|
||||
|
||||
**User Story:** As a developer, I want the history table created via a standard migration script, so that it can be applied to existing deployments without manual intervention.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Migration SHALL create the `compliance_item_history` table if it does not already exist.
|
||||
2. THE Migration SHALL create the required indexes on the `compliance_item_history` table.
|
||||
3. THE Migration SHALL be idempotent and safe to run multiple times without error.
|
||||
4. THE Migration SHALL NOT modify the existing compliance_items table structure.
|
||||
|
||||
### Requirement 8: Per-Metric Metadata API
|
||||
|
||||
**User Story:** As a compliance analyst, I want to set resolution dates and remediation plans for specific metrics within a device, so that I can track different remediation timelines for different compliance failures on the same hostname.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Metadata_API SHALL accept an optional `metric_id` field (string) in the request body to scope the update to a single metric for the given hostname.
|
||||
2. THE Metadata_API SHALL accept an optional `metric_ids` field (array of strings) in the request body to scope the update to multiple specific metrics for the given hostname.
|
||||
3. WHEN `metric_ids` is provided, THE Metadata_API SHALL update only the compliance_items rows matching the specified hostname AND metric_id values.
|
||||
4. WHEN `metric_id` is provided (single string), THE Metadata_API SHALL update only the compliance_items row matching the specified hostname AND metric_id.
|
||||
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Metadata_API SHALL use `metric_ids` and ignore `metric_id`.
|
||||
6. WHEN neither `metric_id` nor `metric_ids` is provided, THE Metadata_API SHALL update all active compliance_items for the hostname, preserving backward compatibility with the existing hostname-level behavior.
|
||||
7. IF a provided metric_id does not correspond to an active compliance_item for the hostname, THEN THE Metadata_API SHALL return a 400 error identifying the invalid metric_id.
|
||||
8. WHEN `metric_ids` is provided, THE Metadata_API SHALL validate that each entry is a non-empty string of 100 characters or fewer.
|
||||
|
||||
### Requirement 9: Per-Metric Metric Selector UI
|
||||
|
||||
**User Story:** As a compliance analyst, I want a metric selector in the detail panel when editing remediation plans, so that I can choose which metrics a resolution date or remediation plan applies to — matching the pattern used for notes.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Detail_Panel SHALL display a Metric_Selector above the resolution date and remediation plan inputs.
|
||||
2. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Detail_Panel SHALL pre-select that metric and display the Metric_Selector as a single non-removable selection.
|
||||
3. THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
|
||||
4. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
|
||||
5. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
|
||||
6. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
|
||||
7. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select all Active_Metrics by default, preserving the existing hostname-level editing experience.
|
||||
|
||||
### Requirement 10: Per-Metric Field Display
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see the current resolution date and remediation plan for the selected metric(s), so that I know what values are already set before making changes.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a single metric is selected in the Metric_Selector, THE Detail_Panel SHALL populate the resolution date and remediation plan inputs with the current values from that specific compliance_item row.
|
||||
2. WHEN multiple metrics are selected and all share the same resolution_date value, THE Detail_Panel SHALL display that shared value in the resolution date input.
|
||||
3. WHEN multiple metrics are selected and they have different resolution_date values, THE Detail_Panel SHALL display the resolution date input as empty with placeholder text indicating "Multiple values".
|
||||
4. WHEN multiple metrics are selected and all share the same remediation_plan value, THE Detail_Panel SHALL display that shared value in the remediation plan input.
|
||||
5. WHEN multiple metrics are selected and they have different remediation_plan values, THE Detail_Panel SHALL display the remediation plan input as empty with placeholder text indicating "Multiple values".
|
||||
6. WHEN the user saves with "Multiple values" placeholder visible and the input left empty, THE Detail_Panel SHALL NOT send that field in the PATCH request, preserving existing per-metric values.
|
||||
|
||||
### Requirement 11: Per-Metric History Tracking
|
||||
|
||||
**User Story:** As a compliance analyst, I want the change history to record which specific metric was changed, so that the audit trail reflects per-metric remediation plan changes accurately.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE compliance_item_history table SHALL include a `metric_id` column (text, nullable) to record which metric the change applies to.
|
||||
2. WHEN a per-metric update changes a field value, THE History_Service SHALL record the metric_id in the History_Entry.
|
||||
3. WHEN a hostname-level update (no metric_id specified) changes a field value, THE History_Service SHALL record the metric_id as NULL in the History_Entry, indicating the change applied to all metrics.
|
||||
4. THE History_Entry SHALL record the old_value and new_value per metric when a per-metric update is performed, reflecting the actual previous value of that specific compliance_item row.
|
||||
5. WHEN a per-metric update targets multiple metrics with different current values, THE History_Service SHALL insert one History_Entry per metric that actually changed, each with its own old_value.
|
||||
6. THE Compliance_API SHALL include the metric_id field in history entries returned to the client.
|
||||
|
||||
### Requirement 12: Per-Metric History Display
|
||||
|
||||
**User Story:** As a compliance analyst, I want the change history section to show which metric each change applied to, so that I can distinguish between hostname-wide changes and metric-specific changes.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a History_Entry has a non-null metric_id, THE Detail_Panel SHALL display the associated Metric_Chip next to the history entry.
|
||||
2. WHEN a History_Entry has a null metric_id, THE Detail_Panel SHALL display "All metrics" label next to the history entry to indicate a hostname-level change.
|
||||
3. THE Detail_Panel SHALL continue to display all history entries for the hostname in reverse chronological order, regardless of metric_id.
|
||||
|
||||
### Requirement 13: Per-Metric Reporting
|
||||
|
||||
**User Story:** As a VCL report consumer, I want burndown forecasts to use per-metric resolution dates, so that the forecast accurately reflects when each individual compliance failure is expected to be resolved.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE VCL_Report SHALL read resolution_date from each individual compliance_items row (per hostname+metric_id pair) for burndown calculations, rather than using a single hostname-level value.
|
||||
2. THE VCL_Report SHALL count each compliance_item row with a null resolution_date as a separate blocker in the donut chart.
|
||||
3. THE VCL_Report SHALL bucket each compliance_item row by its own resolution_date month for the burndown forecast chart.
|
||||
4. WHEN deduplicating by hostname for aggregated views, THE VCL_Report SHALL use the latest (MAX) resolution_date among all active metrics for that hostname.
|
||||
|
||||
### Requirement 14: Per-Metric History Migration
|
||||
|
||||
**User Story:** As a developer, I want the history table extended with a metric_id column via a migration script, so that per-metric history tracking can be deployed to existing environments.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Migration SHALL add a nullable `metric_id` column (text) to the `compliance_item_history` table.
|
||||
2. THE Migration SHALL create an index on (hostname, metric_id) for efficient per-metric history lookups.
|
||||
3. THE Migration SHALL be idempotent and safe to run multiple times without error.
|
||||
4. THE Migration SHALL NOT alter or backfill existing history rows — pre-existing entries retain a NULL metric_id indicating they were hostname-level changes.
|
||||
|
||||
### Requirement 15: Backward Compatibility
|
||||
|
||||
**User Story:** As an existing user of the bulk upload and API workflows, I want hostname-level updates to continue working without modification, so that existing integrations and scripts are not broken by the per-metric change.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Metadata_API receives a request without `metric_id` or `metric_ids`, THE Metadata_API SHALL update all active compliance_items for the hostname, matching the pre-existing behavior.
|
||||
2. WHEN the bulk update commit endpoint processes a row, THE Metadata_API SHALL continue to apply resolution_date and remediation_plan to all active metrics for that hostname.
|
||||
3. THE Detail_Panel SHALL default to "Select All" metrics when first opened, so that saving without changing the metric selection produces the same hostname-level update behavior as before.
|
||||
4. WHEN all metrics are selected and the user saves, THE Metadata_API SHALL NOT include metric_ids in the request body, triggering the hostname-level update path for backward compatibility.
|
||||
85
.kiro/specs/remediation-plan-history/tasks.md
Normal file
85
.kiro/specs/remediation-plan-history/tasks.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Implementation Plan: Remediation Plan History — Per-Metric Extension
|
||||
|
||||
## Overview
|
||||
|
||||
Extends the existing remediation plan history system to support per-metric scoping of resolution_date and remediation_plan. The original hostname-level implementation (Tasks 1–6) is already complete. Tasks 7–12 add metric_id targeting to the PATCH endpoint, a metric selector UI in the detail panel, per-metric history tracking, and verification that reporting queries work correctly with per-metric resolution dates. This addresses GitLab issue #19.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Create migration for compliance_item_history table
|
||||
- [x] 1.1 Create `backend/migrations/add_compliance_item_history.js` with schema, CHECK constraint, indexes, register in run-all.js, and verify table exists
|
||||
- _Requirements: 2, 7_
|
||||
|
||||
- [x] 2. Modify PATCH /items/:hostname/metadata to record history
|
||||
- [x] 2.1 Accept change_reason, SELECT current values, INSERT history for changed fields, wrap in transaction, handle NULL transitions, add audit log
|
||||
- _Requirements: 1, 6_
|
||||
|
||||
- [x] 3. Extend GET /items/:hostname to return history
|
||||
- [x] 3.1 Add history query, include history array in response, graceful degradation on failure
|
||||
- _Requirements: 4_
|
||||
|
||||
- [x] 4. Modify bulk update commit to track history
|
||||
- [x] 4.1 Query current values before update, INSERT history for changed fields, skip unchanged, NULL change_reason
|
||||
- _Requirements: 6_
|
||||
|
||||
- [x] 5. Add change_reason input and history section to ComplianceDetailPanel
|
||||
- [x] 5.1 Add changeReason state and input, pass in PATCH body, display Change History section with formatted entries, run npm run build
|
||||
- _Requirements: 5_
|
||||
|
||||
- [x] 6. Verify VCL burndown is unaffected
|
||||
- [x] 6.1 Confirm burndown and donut queries read from compliance_items.resolution_date only, no code changes
|
||||
- _Requirements: 3_
|
||||
|
||||
- [x] 7. Create migration to add metric_id column to compliance_item_history
|
||||
- [x] 7.1 Create `backend/migrations/add_compliance_history_metric_id.js` that adds nullable `metric_id TEXT` column (idempotent check if column exists), creates index on (hostname, metric_id), register in run-all.js, run migration and verify column exists, verify existing rows retain NULL
|
||||
- _Requirements: 14_
|
||||
|
||||
- [x] 8. Extend PATCH /items/:hostname/metadata to support per-metric scoping
|
||||
- [x] 8.1 Accept optional `metric_id` (string) and `metric_ids` (array) in request body; if both provided use metric_ids; validate non-empty and max 100 chars; validate each corresponds to active compliance_item
|
||||
- _Requirements: 8_
|
||||
- [x] 8.2 When metric_ids provided: SELECT current values per targeted metric, INSERT history with metric_id per changed field, UPDATE only matching rows
|
||||
- _Requirements: 8, 11_
|
||||
- [x] 8.3 When neither metric_id nor metric_ids provided: preserve hostname-level behavior with NULL metric_id in history entries
|
||||
- _Requirements: 15_
|
||||
|
||||
- [x] 9. Extend GET /items/:hostname to include metric_id in history entries
|
||||
- [x] 9.1 Update history query to SELECT metric_id column, include metric_id in each history entry in response, verify NULL entries returned correctly
|
||||
- _Requirements: 4, 11_
|
||||
|
||||
- [x] 10. Add MetricChipSelector for metadata editing in ComplianceDetailPanel
|
||||
- [x] 10.1 Add metricSelection state separate from notes selector, default all active metrics selected on panel open, render MetricChipSelector above resolution_date/remediation_plan inputs with category-colored chips and Select All/Deselect All toggle
|
||||
- _Requirements: 9_
|
||||
- [x] 10.2 Implement computeSharedValues: display shared value when all selected metrics agree, show "Multiple values" placeholder when they differ, omit unchanged fields from PATCH
|
||||
- _Requirements: 10_
|
||||
- [x] 10.3 When all metrics selected omit metric_ids from request body (backward compat), when subset selected include metric_ids, run npm run build
|
||||
- _Requirements: 15_
|
||||
|
||||
- [x] 11. Update history display to show per-metric labels
|
||||
- [x] 11.1 When history entry has non-null metric_id display MetricChip, when null display "All metrics" label, build metricMap from metrics array, verify existing entries display correctly, run npm run build
|
||||
- _Requirements: 12_
|
||||
|
||||
- [x] 12. Verify per-metric burndown reporting works correctly
|
||||
- [x] 12.1 Confirm burndown forecast reads resolution_date per compliance_items row, donut uses per-row presence, aggregated view uses MAX(resolution_date), no code changes expected
|
||||
- _Requirements: 13_
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks 1–6 are already implemented (hostname-level history tracking is live in production)
|
||||
- Tasks 7–12 implement the per-metric extension from GitLab issue #19
|
||||
- Task 7 must run first as it adds the metric_id column needed by all subsequent tasks
|
||||
- Tasks 10 and 11 can run in parallel after Task 9 completes
|
||||
- Task 12 is verification-only and depends on both frontend tasks completing
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["7.1"] },
|
||||
{ "id": 1, "tasks": ["8.1", "8.2", "8.3"] },
|
||||
{ "id": 2, "tasks": ["9.1"] },
|
||||
{ "id": 3, "tasks": ["10.1", "10.2", "10.3", "11.1"] },
|
||||
{ "id": 4, "tasks": ["12.1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
1
.kiro/specs/vcl-aggregated-burndown/.config.kiro
Normal file
1
.kiro/specs/vcl-aggregated-burndown/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "c717b04e-9452-4390-99bb-2c6871c1b9bd", "workflowType": "requirements-first", "specType": "feature"}
|
||||
319
.kiro/specs/vcl-aggregated-burndown/design.md
Normal file
319
.kiro/specs/vcl-aggregated-burndown/design.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Design Document: VCL Aggregated Burndown
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds an aggregated (cross-vertical) burndown forecast to the CCP Metrics overview page. Currently, burndown data is only available per-vertical via `GET /api/compliance/vcl-multi/vertical/:code/burndown`. This feature introduces a new endpoint `GET /api/compliance/vcl-multi/burndown` that rolls up burndown data across all verticals, a new pure helper function `computeAggregatedBurndown` for testable computation logic, and a new `AggregatedBurndownChart` React component displayed on the overview page.
|
||||
|
||||
The design reuses the existing `computeVerticalBurndown` pattern from `vclHelpers.js` and extends it with hostname deduplication (a device appearing in multiple metrics counts once) and per-vertical contribution breakdown. The frontend component follows the same Recharts `BarChart` pattern used in the per-vertical burndown chart within `VerticalDetailView`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as CCPMetricsPage
|
||||
participant BE as Express Backend
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over FE,DB: Overview Page Load (existing + new)
|
||||
FE->>BE: GET /api/compliance/vcl-multi/stats
|
||||
BE->>DB: Aggregate stats across verticals
|
||||
BE-->>FE: { stats, donut, vertical_breakdown }
|
||||
|
||||
FE->>BE: GET /api/compliance/vcl-multi/trend
|
||||
BE->>DB: Monthly snapshots
|
||||
BE-->>FE: { months: [...] }
|
||||
|
||||
FE->>BE: GET /api/compliance/vcl-multi/burndown
|
||||
BE->>DB: SELECT hostname, resolution_date, vertical FROM compliance_items WHERE vertical IS NOT NULL AND status = 'active'
|
||||
BE->>BE: Deduplicate by hostname (earliest resolution_date)
|
||||
BE->>BE: computeAggregatedBurndown(devices)
|
||||
BE-->>FE: { total_non_compliant, blockers, with_dates, monthly_forecast, projected_clear_date, by_vertical }
|
||||
|
||||
FE->>FE: Render AggregatedBurndownChart
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Query** — Fetch all active non-compliant devices across all verticals from `compliance_items`.
|
||||
2. **Deduplicate** — Group by hostname, keeping the earliest non-null `resolution_date` across metric entries. A device appearing in 3 metrics counts as 1 device.
|
||||
3. **Compute** — Pass deduplicated device list to `computeAggregatedBurndown` which produces totals, monthly buckets, cumulative projection, and per-vertical breakdown.
|
||||
4. **Respond** — Return the computed burndown data to the frontend.
|
||||
5. **Render** — `AggregatedBurndownChart` displays a bar chart of monthly remediations, summary stats header, and per-vertical contribution table.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend
|
||||
|
||||
#### New Endpoint
|
||||
|
||||
**`GET /api/compliance/vcl-multi/burndown`**
|
||||
|
||||
Returns aggregated burndown forecast across all verticals.
|
||||
|
||||
- Auth: `requireAuth()`
|
||||
- Route file: `backend/routes/vclMultiVertical.js`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"total_non_compliant": 400,
|
||||
"blockers": 120,
|
||||
"with_dates": 280,
|
||||
"monthly_forecast": {
|
||||
"2026-06": 85,
|
||||
"2026-07": 110,
|
||||
"2026-08": 55,
|
||||
"2026-09": 30
|
||||
},
|
||||
"projected_clear_date": "2026-09",
|
||||
"by_vertical": [
|
||||
{ "vertical": "NTS_AEO", "total": 180, "blockers": 50, "with_dates": 130 },
|
||||
{ "vertical": "SDIT_CISO", "total": 120, "blockers": 40, "with_dates": 80 },
|
||||
{ "vertical": "TSI", "total": 100, "blockers": 30, "with_dates": 70 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When no active non-compliant devices exist:
|
||||
```json
|
||||
{
|
||||
"total_non_compliant": 0,
|
||||
"blockers": 0,
|
||||
"with_dates": 0,
|
||||
"monthly_forecast": {},
|
||||
"projected_clear_date": null,
|
||||
"by_vertical": []
|
||||
}
|
||||
```
|
||||
|
||||
#### New Pure Helper Function
|
||||
|
||||
Added to `backend/helpers/vclHelpers.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
|
||||
* A device appearing in multiple metrics counts once.
|
||||
*
|
||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
|
||||
* @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
|
||||
*/
|
||||
function deduplicateByHostname(items) { ... }
|
||||
|
||||
/**
|
||||
* Computes aggregated burndown from a deduplicated array of device objects.
|
||||
* Each device has { hostname, resolution_date, vertical }.
|
||||
*
|
||||
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
|
||||
* @returns {{
|
||||
* total: number,
|
||||
* blockers: number,
|
||||
* with_dates: number,
|
||||
* monthly: Object<string, number>,
|
||||
* projection: Object<string, { remediated: number, remaining: number }>,
|
||||
* projected_clear_date: string|null,
|
||||
* by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
|
||||
* }}
|
||||
*/
|
||||
function computeAggregatedBurndown(devices) { ... }
|
||||
```
|
||||
|
||||
**`deduplicateByHostname` logic:**
|
||||
- Groups items by hostname
|
||||
- For each hostname, selects the earliest non-null `resolution_date` across all entries
|
||||
- If all entries for a hostname have null dates, the device is a blocker
|
||||
- Preserves the `vertical` from the first entry (for per-vertical breakdown, the endpoint groups separately)
|
||||
|
||||
**`computeAggregatedBurndown` logic:**
|
||||
- Counts total devices, blockers (null date), and with_dates (non-null date)
|
||||
- Buckets with_dates devices by YYYY-MM of resolution_date into `monthly`
|
||||
- Sorts monthly keys chronologically
|
||||
- Computes `projection` as cumulative remaining: starts at `total`, subtracts each month's count
|
||||
- Sets `projected_clear_date` to the last month key if blockers = 0, otherwise null
|
||||
- Groups devices by vertical for `by_vertical`, sorted descending by total, omitting verticals with zero devices
|
||||
|
||||
#### Endpoint Implementation Pattern
|
||||
|
||||
The endpoint follows the same pattern as the existing per-vertical burndown:
|
||||
|
||||
```javascript
|
||||
router.get('/burndown', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'`
|
||||
);
|
||||
|
||||
// Deduplicate by hostname (earliest non-null resolution_date)
|
||||
const devices = deduplicateByHostname(rows);
|
||||
const burndown = computeAggregatedBurndown(devices);
|
||||
|
||||
res.json({
|
||||
total_non_compliant: burndown.total,
|
||||
blockers: burndown.blockers,
|
||||
with_dates: burndown.with_dates,
|
||||
monthly_forecast: burndown.monthly,
|
||||
projected_clear_date: burndown.projected_clear_date,
|
||||
by_vertical: burndown.by_vertical,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /burndown error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
#### New Component: `AggregatedBurndownChart`
|
||||
|
||||
Inline component within `CCPMetricsPage.js` (following the existing pattern where `StatsBar`, `DonutChart`, `TrendChart`, and `VerticalTable` are all defined in the same file).
|
||||
|
||||
**Placement:** Below the existing charts row (TrendChart + DonutChart), above the VerticalTable.
|
||||
|
||||
**Behavior:**
|
||||
- Fetches `GET /api/compliance/vcl-multi/burndown` on page load alongside existing stats/trend calls
|
||||
- Displays a summary header with total non-compliant, blockers, in-progress, and projected clear date
|
||||
- Renders a Recharts `BarChart` with one bar per monthly bucket (purple fill, matching existing burndown chart style)
|
||||
- Below the chart, renders a compact per-vertical contribution table sorted by total descending
|
||||
- Shows "No non-compliant devices" message when total = 0
|
||||
- Shows "All X non-compliant devices lack remediation dates" when monthly_forecast is empty but blockers > 0
|
||||
- Shows loading spinner while fetching
|
||||
- Shows inline error message on API failure
|
||||
|
||||
**Chart specification:**
|
||||
```javascript
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={monthlyData}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
|
||||
<Bar dataKey="count" fill="#A78BFA" fillOpacity={0.7} name="Projected Remediations" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
No schema changes required. The feature reads from the existing `compliance_items` table:
|
||||
|
||||
```sql
|
||||
-- Existing columns used:
|
||||
-- hostname TEXT
|
||||
-- vertical TEXT (nullable)
|
||||
-- status TEXT ('active'|'resolved')
|
||||
-- resolution_date DATE (nullable)
|
||||
```
|
||||
|
||||
The query for the aggregated burndown:
|
||||
```sql
|
||||
SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'
|
||||
```
|
||||
|
||||
This is the same data source used by the per-vertical burndown endpoint, just without the `vertical = $1` filter.
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: Partition Invariant
|
||||
|
||||
*For any* array of device objects passed to `computeAggregatedBurndown`, the result must satisfy `blockers + with_dates = total`. Every device is either a blocker (null resolution_date) or in-progress (non-null resolution_date), with no device uncounted or double-counted.
|
||||
|
||||
**Validates: Requirements 2.2**
|
||||
|
||||
### Property 2: Monthly Bucket Conservation
|
||||
|
||||
*For any* array of device objects passed to `computeAggregatedBurndown`, the sum of all values in the `monthly` object must equal `with_dates`. Every in-progress device appears in exactly one monthly bucket, and no device is lost or duplicated during bucketing.
|
||||
|
||||
**Validates: Requirements 2.3, 1.5**
|
||||
|
||||
### Property 3: Chronological Monthly Ordering
|
||||
|
||||
*For any* array of device objects passed to `computeAggregatedBurndown`, the keys of the `monthly` object must be in ascending chronological order (lexicographic sort of YYYY-MM strings).
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
### Property 4: Cumulative Projection Consistency
|
||||
|
||||
*For any* array of device objects passed to `computeAggregatedBurndown`, the `projection` object must satisfy: for each month in chronological order, `projection[month].remaining = total - (cumulative sum of monthly[m] for all m <= month)`. The first month's remaining equals `total - monthly[first_month]`.
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 5: Projected Clear Date Logic
|
||||
|
||||
*For any* array of device objects passed to `computeAggregatedBurndown`: if `blockers > 0`, then `projected_clear_date` must be `null`; if `blockers = 0` and `with_dates > 0`, then `projected_clear_date` must equal the last (chronologically greatest) key in `monthly`.
|
||||
|
||||
**Validates: Requirements 1.7**
|
||||
|
||||
### Property 6: Hostname Deduplication with Earliest Date
|
||||
|
||||
*For any* array of items where the same hostname appears multiple times with different resolution_dates, `deduplicateByHostname` must produce exactly one entry per unique hostname, and that entry's `resolution_date` must be the earliest non-null date among all entries for that hostname (or null if all entries have null dates).
|
||||
|
||||
**Validates: Requirements 1.6**
|
||||
|
||||
### Property 7: Aggregation Consistency with Per-Vertical Computation
|
||||
|
||||
*For any* array of device objects spanning multiple verticals, the aggregated `total` must equal the sum of per-vertical totals, the aggregated `blockers` must equal the sum of per-vertical blockers, the aggregated `with_dates` must equal the sum of per-vertical with_dates, and for each month key, the aggregated monthly count must equal the sum of that month's count across all per-vertical computations.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||
|
||||
### Property 8: By-Vertical Sorting and Filtering
|
||||
|
||||
*For any* array of device objects spanning multiple verticals, the `by_vertical` array must be sorted in descending order by `total`, must not contain any entry where `total = 0`, and the sum of all `by_vertical[i].total` must equal the overall `total`.
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.4**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | HTTP Status | Response | Behavior |
|
||||
|---|---|---|---|
|
||||
| Database error | 500 | `{ "error": "Database error" }` | Log error, return 500 |
|
||||
| Unauthenticated request | 401 | `{ "error": "Authentication required" }` | Middleware rejects |
|
||||
| No active non-compliant devices | 200 | Zero/empty response (see above) | Graceful empty state |
|
||||
|
||||
Frontend error handling:
|
||||
- API failure: inline error message in red monospace text, consistent with existing error patterns on the page
|
||||
- Loading state: `<Loader />` spinner with "Loading..." text
|
||||
- Empty state (total = 0): informational message instead of empty chart
|
||||
- All blockers (monthly empty, blockers > 0): message indicating all devices lack dates
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Testing
|
||||
|
||||
Use `fast-check` (already used in this project). Each correctness property maps to a single property-based test with minimum 100 iterations.
|
||||
|
||||
Property tests target the pure helper functions exported from `backend/helpers/vclHelpers.js`:
|
||||
- `deduplicateByHostname` — Property 6
|
||||
- `computeAggregatedBurndown` — Properties 1, 2, 3, 4, 5, 7, 8
|
||||
|
||||
Tag format: **Feature: vcl-aggregated-burndown, Property {number}: {title}**
|
||||
|
||||
Test file: `backend/__tests__/vcl-aggregated-burndown.property.test.js`
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Unit tests cover specific examples and edge cases:
|
||||
|
||||
- **Empty input** — verify all-zero response (Requirement 2.6)
|
||||
- **All blockers** — verify with_dates = 0, monthly = {}, projected_clear_date = null (Requirement 2.7)
|
||||
- **Single device, single metric** — verify basic computation
|
||||
- **Duplicate hostnames across metrics** — verify deduplication picks earliest date
|
||||
- **Duplicate hostnames where all dates are null** — verify device is a blocker
|
||||
- **API endpoint integration** — verify response shape with mocked DB data
|
||||
- **Auth middleware** — verify 401 without session
|
||||
|
||||
Test file: `backend/__tests__/vcl-aggregated-burndown.test.js`
|
||||
|
||||
### Frontend Testing
|
||||
|
||||
- Component renders loading state
|
||||
- Component renders empty state message when total = 0
|
||||
- Component renders blocker-only message when monthly is empty
|
||||
- Component renders bar chart with correct data
|
||||
- Component renders per-vertical table sorted correctly
|
||||
- Component renders error message on API failure
|
||||
84
.kiro/specs/vcl-aggregated-burndown/requirements.md
Normal file
84
.kiro/specs/vcl-aggregated-burndown/requirements.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature adds an aggregated (cross-vertical) burndown forecast chart to the main CCP Metrics overview page. Currently, burndown forecasts only exist at the per-vertical drill-down level — when a user clicks into a specific vertical (e.g., NTS_AEO), they see that vertical's burndown showing blockers, devices with dates, and monthly projected remediation. This feature rolls up the same burndown concept across ALL verticals and displays it on the overview page alongside the existing Stats Bar, Compliance Trend chart, and Non-Compliant Status donut chart.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Aggregated_Burndown_API**: The backend endpoint that computes burndown forecast data across all verticals by querying active non-compliant devices from `compliance_items` where `vertical IS NOT NULL`.
|
||||
- **Burndown_Chart**: A Recharts-based bar chart component that visualizes monthly projected remediation counts and cumulative remaining non-compliant devices across all verticals.
|
||||
- **Blocker**: A non-compliant device in `compliance_items` with `status = 'active'` and `resolution_date IS NULL` — no committed remediation timeline exists.
|
||||
- **In_Progress_Device**: A non-compliant device in `compliance_items` with `status = 'active'` and a non-null `resolution_date` — a target remediation date has been set.
|
||||
- **Monthly_Bucket**: A grouping of in-progress devices by the month portion (YYYY-MM) of their `resolution_date`, representing how many devices are expected to be remediated in that month.
|
||||
- **Projected_Clear_Date**: The earliest month by which all in-progress devices (those with resolution dates) are projected to be remediated, assuming blockers remain unresolved.
|
||||
- **Overview_Page**: The main CCP Metrics page (`CCPMetricsPage.js`) that displays aggregated cross-vertical statistics before any vertical drill-down is selected.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Aggregated Burndown API Endpoint
|
||||
|
||||
**User Story:** As a compliance analyst, I want a single API endpoint that returns burndown forecast data aggregated across all verticals, so that the overview page can display a cross-organizational remediation projection without requiring multiple per-vertical API calls.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a GET request is made to `/api/compliance/vcl-multi/burndown`, THE Aggregated_Burndown_API SHALL return a JSON response containing `total_non_compliant`, `blockers`, `with_dates`, `monthly_forecast`, and `projected_clear_date` fields.
|
||||
2. THE Aggregated_Burndown_API SHALL compute `total_non_compliant` as the count of distinct hostnames in `compliance_items` where `vertical IS NOT NULL` and `status = 'active'`.
|
||||
3. THE Aggregated_Burndown_API SHALL compute `blockers` as the count of distinct hostnames where `resolution_date IS NULL` among active non-compliant devices across all verticals.
|
||||
4. THE Aggregated_Burndown_API SHALL compute `with_dates` as the count of distinct hostnames where `resolution_date IS NOT NULL` among active non-compliant devices across all verticals.
|
||||
5. THE Aggregated_Burndown_API SHALL compute `monthly_forecast` by bucketing in-progress devices into Monthly_Buckets based on the YYYY-MM portion of their `resolution_date`, with each bucket containing the count of devices projected to be remediated in that month.
|
||||
6. THE Aggregated_Burndown_API SHALL deduplicate devices by hostname before computing burndown totals — a device appearing in multiple metrics counts once, using the earliest non-null `resolution_date` across its metric entries.
|
||||
7. THE Aggregated_Burndown_API SHALL return `projected_clear_date` as the last month in the sorted monthly forecast when all in-progress devices have been accounted for, or `null` when blockers exist.
|
||||
8. WHEN no active non-compliant devices exist across any vertical, THE Aggregated_Burndown_API SHALL return `total_non_compliant: 0`, `blockers: 0`, `with_dates: 0`, `monthly_forecast: {}`, and `projected_clear_date: null`.
|
||||
9. THE Aggregated_Burndown_API SHALL require authentication via `requireAuth()` middleware.
|
||||
|
||||
### Requirement 2: Aggregated Burndown Computation Logic
|
||||
|
||||
**User Story:** As a developer, I want a pure helper function that computes aggregated burndown from a set of device objects, so that the computation is testable in isolation and reusable.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE `computeAggregatedBurndown` helper function SHALL accept an array of device objects (each with `hostname` and `resolution_date` fields) and return an object with `total`, `blockers`, `with_dates`, `monthly`, `projection`, and `projected_clear_date` fields.
|
||||
2. FOR ALL valid input arrays, THE `computeAggregatedBurndown` function SHALL satisfy the invariant: `blockers + with_dates = total`.
|
||||
3. FOR ALL valid input arrays, THE `computeAggregatedBurndown` function SHALL satisfy the invariant: the sum of all values in `monthly` equals `with_dates`.
|
||||
4. THE `computeAggregatedBurndown` function SHALL produce `monthly` buckets sorted chronologically by month key (YYYY-MM format).
|
||||
5. THE `computeAggregatedBurndown` function SHALL compute `projection` as a cumulative remaining count per month — starting from `total` and subtracting each month's remediated count in chronological order.
|
||||
6. WHEN the input array is empty, THE `computeAggregatedBurndown` function SHALL return `total: 0`, `blockers: 0`, `with_dates: 0`, `monthly: {}`, `projection: {}`, and `projected_clear_date: null`.
|
||||
7. WHEN all devices have `resolution_date = null`, THE `computeAggregatedBurndown` function SHALL return `blockers` equal to `total`, `with_dates: 0`, `monthly: {}`, and `projected_clear_date: null`.
|
||||
|
||||
### Requirement 3: Burndown Chart Frontend Component
|
||||
|
||||
**User Story:** As a senior leader viewing the CCP Metrics overview page, I want to see an aggregated burndown forecast chart showing when non-compliant devices across all verticals are projected to be remediated, so that I can assess organizational remediation progress at a glance.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Burndown_Chart SHALL be displayed on the Overview_Page below the Compliance Trend chart and Non-Compliant Status donut chart section.
|
||||
2. THE Burndown_Chart SHALL display a bar chart with one bar per Monthly_Bucket, where the bar height represents the count of devices projected to be remediated in that month.
|
||||
3. THE Burndown_Chart SHALL display a summary header showing the total non-compliant count, blocker count, in-progress count, and projected clear date.
|
||||
4. THE Burndown_Chart SHALL use the Recharts `BarChart` component with styling consistent with the existing Compliance Trend chart (dark background, teal/purple color palette, monospace axis labels).
|
||||
5. WHEN the Aggregated_Burndown_API returns `total_non_compliant: 0`, THE Burndown_Chart SHALL display a message indicating no non-compliant devices exist rather than rendering an empty chart.
|
||||
6. WHEN the Aggregated_Burndown_API returns `monthly_forecast` with zero entries but `blockers > 0`, THE Burndown_Chart SHALL display the blocker count with a message indicating all non-compliant devices lack remediation dates.
|
||||
7. THE Burndown_Chart SHALL fetch data from the Aggregated_Burndown_API on page load and display a loading indicator while the request is in flight.
|
||||
8. IF the Aggregated_Burndown_API request fails, THEN THE Burndown_Chart SHALL display an inline error message consistent with the existing error display pattern on the Overview_Page.
|
||||
|
||||
### Requirement 4: Burndown Data Consistency
|
||||
|
||||
**User Story:** As a compliance analyst, I want the aggregated burndown numbers to be consistent with the per-vertical burndown data, so that I can trust the overview numbers match the sum of individual verticals.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `total_non_compliant` SHALL equal the sum of `total_non_compliant` values returned by each individual per-vertical burndown endpoint (`GET /api/compliance/vcl-multi/vertical/:code/burndown`).
|
||||
2. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `blockers` SHALL equal the sum of `blockers` values returned by each individual per-vertical burndown endpoint.
|
||||
3. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `with_dates` SHALL equal the sum of `with_dates` values returned by each individual per-vertical burndown endpoint.
|
||||
4. FOR ALL monthly buckets, THE Aggregated_Burndown_API monthly forecast count for a given month SHALL equal the sum of that month's forecast count across all per-vertical burndown responses.
|
||||
|
||||
### Requirement 5: Per-Vertical Contribution Breakdown
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see which verticals contribute the most to the aggregated burndown, so that I can identify which organizations need the most attention for remediation planning.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Aggregated_Burndown_API response SHALL include a `by_vertical` array containing per-vertical breakdowns with fields: `vertical`, `total`, `blockers`, `with_dates`.
|
||||
2. THE `by_vertical` array SHALL be sorted in descending order by `total` (most non-compliant devices first).
|
||||
3. THE Burndown_Chart component SHALL display the per-vertical breakdown below the bar chart as a compact summary table showing each vertical's contribution to the overall burndown.
|
||||
4. WHEN a vertical has zero active non-compliant devices, THE Aggregated_Burndown_API SHALL omit that vertical from the `by_vertical` array.
|
||||
113
.kiro/specs/vcl-aggregated-burndown/tasks.md
Normal file
113
.kiro/specs/vcl-aggregated-burndown/tasks.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Implementation Plan: VCL Aggregated Burndown
|
||||
|
||||
## Overview
|
||||
|
||||
Implement an aggregated cross-vertical burndown forecast feature consisting of: two new pure helper functions (`deduplicateByHostname` and `computeAggregatedBurndown`) in `vclHelpers.js`, a new `GET /api/compliance/vcl-multi/burndown` endpoint in the existing `vclMultiVertical.js` route file, property-based tests validating 8 correctness properties, unit tests covering edge cases and API integration, and an `AggregatedBurndownChart` inline component in `CCPMetricsPage.js`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Implement backend helper functions
|
||||
- [x] 1.1 Add `deduplicateByHostname` function to `backend/helpers/vclHelpers.js`
|
||||
- Groups items by hostname
|
||||
- For each hostname, selects the earliest non-null `resolution_date` across all entries
|
||||
- If all entries for a hostname have null dates, the device is a blocker (null date preserved)
|
||||
- Preserves the `vertical` from the first entry for that hostname
|
||||
- Export the function from the module
|
||||
- _Requirements: 1.6_
|
||||
|
||||
- [x] 1.2 Add `computeAggregatedBurndown` function to `backend/helpers/vclHelpers.js`
|
||||
- Accepts an array of device objects with `hostname`, `resolution_date`, and `vertical` fields
|
||||
- Counts total devices, blockers (null date), and with_dates (non-null date)
|
||||
- Buckets with_dates devices by YYYY-MM of resolution_date into `monthly` object
|
||||
- Sorts monthly keys chronologically
|
||||
- Computes `projection` as cumulative remaining: starts at `total`, subtracts each month's count
|
||||
- Sets `projected_clear_date` to the last month key if blockers = 0, otherwise null
|
||||
- Groups devices by vertical for `by_vertical`, sorted descending by total, omitting verticals with zero devices
|
||||
- Each `by_vertical` entry has `{ vertical, total, blockers, with_dates }`
|
||||
- Returns `{ total, blockers, with_dates, monthly, projection, projected_clear_date, by_vertical }`
|
||||
- Export the function from the module
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 5.1, 5.2, 5.4_
|
||||
|
||||
- [x]* 1.3 Write property tests for `deduplicateByHostname` and `computeAggregatedBurndown`
|
||||
- **Property 1: Partition Invariant** — `blockers + with_dates = total` for any input
|
||||
- **Validates: Requirements 2.2**
|
||||
- **Property 2: Monthly Bucket Conservation** — sum of monthly values = with_dates
|
||||
- **Validates: Requirements 2.3, 1.5**
|
||||
- **Property 3: Chronological Monthly Ordering** — monthly keys in ascending YYYY-MM order
|
||||
- **Validates: Requirements 2.4**
|
||||
- **Property 4: Cumulative Projection Consistency** — projection[month].remaining = total - cumulative sum
|
||||
- **Validates: Requirements 2.5**
|
||||
- **Property 5: Projected Clear Date Logic** — null when blockers > 0, last month key when blockers = 0
|
||||
- **Validates: Requirements 1.7**
|
||||
- **Property 6: Hostname Deduplication with Earliest Date** — one entry per hostname, earliest non-null date
|
||||
- **Validates: Requirements 1.6**
|
||||
- **Property 7: Aggregation Consistency with Per-Vertical Computation** — aggregated totals = sum of per-vertical totals
|
||||
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||
- **Property 8: By-Vertical Sorting and Filtering** — sorted descending by total, no zero-total entries, sum = overall total
|
||||
- **Validates: Requirements 5.1, 5.2, 5.4**
|
||||
- Test file: `backend/__tests__/vcl-aggregated-burndown.property.test.js`
|
||||
|
||||
- [x] 2. Implement backend API endpoint
|
||||
- [x] 2.1 Add `GET /burndown` route to `backend/routes/vclMultiVertical.js`
|
||||
- Query `compliance_items` for all active non-compliant devices across verticals
|
||||
- Call `deduplicateByHostname` on the query results
|
||||
- Call `computeAggregatedBurndown` on the deduplicated devices
|
||||
- Map the result to the API response shape: `{ total_non_compliant, blockers, with_dates, monthly_forecast, projected_clear_date, by_vertical }`
|
||||
- Handle database errors with 500 status and `{ error: "Database error" }`
|
||||
- Route is protected by `requireAuth()` (already applied via `router.use`)
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9_
|
||||
|
||||
- [x]* 2.2 Write unit tests for the burndown endpoint
|
||||
- Test empty DB returns zero/empty response (Requirement 1.8)
|
||||
- Test all-blocker scenario returns with_dates=0, monthly={}, projected_clear_date=null (Requirement 2.7)
|
||||
- Test single device single metric basic computation
|
||||
- Test duplicate hostnames across metrics — verify deduplication picks earliest date
|
||||
- Test duplicate hostnames where all dates are null — verify device is a blocker
|
||||
- Test response shape matches API contract
|
||||
- Test 401 without auth session (mock requireAuth to reject)
|
||||
- Test file: `backend/__tests__/vcl-aggregated-burndown.test.js`
|
||||
- _Requirements: 1.1, 1.8, 1.9, 2.6, 2.7_
|
||||
|
||||
- [x] 3. Checkpoint - Ensure all backend tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Implement frontend component
|
||||
- [x] 4.1 Add `AggregatedBurndownChart` component to `frontend/src/components/pages/CCPMetricsPage.js`
|
||||
- Add as an inline component following the existing pattern (StatsBar, DonutChart, TrendChart are all in the same file)
|
||||
- Fetch `GET /api/compliance/vcl-multi/burndown` on page load alongside existing stats/trend calls
|
||||
- Display summary header with total non-compliant, blockers, in-progress, and projected clear date
|
||||
- Render a Recharts `BarChart` with one bar per monthly bucket (purple fill `#A78BFA`, fillOpacity 0.7)
|
||||
- Below the chart, render a compact per-vertical contribution table sorted by total descending
|
||||
- Show "No non-compliant devices" message when total = 0
|
||||
- Show "All X non-compliant devices lack remediation dates" when monthly_forecast is empty but blockers > 0
|
||||
- Show `<Loader />` spinner while fetching
|
||||
- Show inline error message on API failure
|
||||
- Place the component below the charts row (TrendChart + DonutChart), above the VerticalTable
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 5.3_
|
||||
|
||||
- [x] 5. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The design uses JavaScript — all implementations use Node.js/Express (backend) and React 19 (frontend)
|
||||
- The `vclMultiVertical.js` route file already exists with `router.use(requireAuth())` applied globally
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["1.2"] },
|
||||
{ "id": 2, "tasks": ["1.3", "2.1"] },
|
||||
{ "id": 3, "tasks": ["2.2"] },
|
||||
{ "id": 4, "tasks": ["4.1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
537
.kiro/specs/vcl-multi-vertical-upload/design.md
Normal file
537
.kiro/specs/vcl-multi-vertical-upload/design.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Design Document: VCL Multi-Vertical Upload
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds a multi-file upload flow to the VCL reporting page that accepts per-vertical compliance xlsx files, stores them with vertical-scoped resolution logic, and generates cross-organizational executive reports with drill-down capability by vertical and metric. It is designed as a POC for the compliance team to evaluate before eventual CyberMetrics API integration.
|
||||
|
||||
The feature is architecturally separate from the existing single-file AEO compliance upload. It reuses the same Python parser and database schema (with additions), but introduces vertical-scoped commit logic and a new set of API endpoints prefixed with `/api/compliance/vcl-multi/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Compliance Analyst
|
||||
participant FE as React Frontend
|
||||
participant BE as Express Backend
|
||||
participant PY as Python Parser
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over U,DB: Multi-File Upload Flow
|
||||
U->>FE: Drop/select 1–14 xlsx files
|
||||
FE->>FE: Extract vertical + date from each filename
|
||||
FE->>BE: POST /api/compliance/vcl-multi/preview (multipart, multiple files)
|
||||
|
||||
loop For each file
|
||||
BE->>PY: parse_compliance_xlsx.py <file>
|
||||
PY-->>BE: { items, summary, report_date, total }
|
||||
BE->>DB: Query active items WHERE vertical = X
|
||||
BE->>BE: Compute scoped diff (new/recurring/resolved within vertical)
|
||||
end
|
||||
|
||||
BE-->>FE: { files: [{ vertical, date, diff, itemCount, tempFile }] }
|
||||
FE->>FE: Display batch preview table
|
||||
U->>FE: Confirm batch
|
||||
FE->>BE: POST /api/compliance/vcl-multi/commit { files: [...] }
|
||||
|
||||
loop For each file (single transaction)
|
||||
BE->>DB: Upsert items for vertical X
|
||||
BE->>DB: Resolve missing items WHERE vertical = X only
|
||||
BE->>DB: Update/create snapshot for vertical X
|
||||
end
|
||||
|
||||
BE-->>FE: { committed: [...] }
|
||||
|
||||
Note over FE,DB: VCL Multi-Vertical Report Load
|
||||
FE->>BE: GET /api/compliance/vcl-multi/stats
|
||||
BE->>DB: Aggregate across all verticals
|
||||
BE-->>FE: { stats, verticalBreakdown, donut }
|
||||
|
||||
FE->>BE: GET /api/compliance/vcl-multi/vertical/:code/metrics
|
||||
BE->>DB: Per-metric breakdown for vertical
|
||||
BE-->>FE: { metrics: [...] }
|
||||
|
||||
FE->>BE: GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices
|
||||
BE->>DB: Device list for vertical + metric
|
||||
BE-->>FE: { devices: [...] }
|
||||
```
|
||||
|
||||
### Data Flow Summary
|
||||
|
||||
1. **Upload** — Multiple files uploaded simultaneously. Each file is parsed independently. Vertical identity comes from the filename, not from inside the xlsx.
|
||||
2. **Scoped resolution** — Each file's commit only resolves items within its own vertical. Other verticals are untouched.
|
||||
3. **Aggregation** — VCL stats endpoints aggregate across all verticals for the executive view.
|
||||
4. **Drill-down** — Vertical → Metric → Device hierarchy for investigation.
|
||||
5. **Burndown** — Computed from `resolution_date` values on non-compliant devices, bucketed by month per vertical.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Schema Changes
|
||||
|
||||
#### New column on `compliance_items`
|
||||
|
||||
```sql
|
||||
ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical);
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status);
|
||||
```
|
||||
|
||||
The `vertical` column stores the organizational vertical code (NTS_AEO, SDIT_CISO, etc.) extracted from the filename at upload time. Existing items (from the old single-file flow) will have `vertical = NULL` — they continue to work with the existing AEO compliance page unchanged.
|
||||
|
||||
#### New column on `compliance_uploads`
|
||||
|
||||
```sql
|
||||
ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL;
|
||||
```
|
||||
|
||||
Tags each upload record with its vertical so we can query upload history per vertical.
|
||||
|
||||
#### New table: `vcl_multi_vertical_summary`
|
||||
|
||||
Stores the parsed Summary sheet data per vertical per upload for metric-level reporting.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||
id SERIAL PRIMARY KEY,
|
||||
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
vertical TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT DEFAULT '',
|
||||
category TEXT DEFAULT 'Other',
|
||||
team TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT '',
|
||||
non_compliant INTEGER DEFAULT 0,
|
||||
compliant INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0,
|
||||
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||
target NUMERIC(5,2) DEFAULT 0,
|
||||
status TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical
|
||||
ON vcl_multi_vertical_summary(vertical);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload
|
||||
ON vcl_multi_vertical_summary(upload_id);
|
||||
```
|
||||
|
||||
#### Updated `compliance_snapshots`
|
||||
|
||||
The existing snapshots table already has a `vertical` column. Multi-vertical uploads will create snapshots keyed on the vertical code (NTS_AEO, SDIT_CISO) rather than the team name (STEAM, ACCESS-ENG). An additional `_ALL` aggregate snapshot is created for the trend chart.
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
```
|
||||
compliance_uploads (1) ──── (N) compliance_items
|
||||
│ │
|
||||
│ vertical │ vertical
|
||||
│ │
|
||||
└──── (N) vcl_multi_vertical_summary
|
||||
│
|
||||
compliance_snapshots ─────────────────┘ (keyed on vertical + month)
|
||||
```
|
||||
|
||||
### Vertical Identification Logic
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Extracts vertical code and report date from a filename.
|
||||
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||
* Examples:
|
||||
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||
*/
|
||||
function parseVerticalFilename(filename) {
|
||||
const stem = filename.replace(/\.xlsx$/i, '');
|
||||
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
vertical: match[1],
|
||||
date: `${match[2]}-${match[3]}-${match[4]}`,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Upload Flow
|
||||
|
||||
**`POST /api/compliance/vcl-multi/preview`**
|
||||
|
||||
Accepts multiple xlsx files via multipart form data. Parses each, computes per-vertical scoped diffs.
|
||||
|
||||
- Auth: `requireAuth()`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Body: multipart/form-data with field `files` (array of xlsx files)
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"filename": "NTS_AEO_2026_05_11.xlsx",
|
||||
"vertical": "NTS_AEO",
|
||||
"report_date": "2026-05-11",
|
||||
"total_items": 342,
|
||||
"diff": { "new_count": 12, "recurring_count": 320, "resolved_count": 8 },
|
||||
"summary_entries": 24,
|
||||
"tempFile": "/path/to/temp.json"
|
||||
}
|
||||
],
|
||||
"unrecognized": ["weird_file.xlsx"]
|
||||
}
|
||||
```
|
||||
|
||||
**`POST /api/compliance/vcl-multi/commit`**
|
||||
|
||||
Commits all previewed files in a single transaction.
|
||||
|
||||
- Auth: `requireAuth()`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Body: `{ files: [{ tempFile, vertical, report_date, filename }] }`
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"committed": [
|
||||
{ "vertical": "NTS_AEO", "upload_id": 45, "new_count": 12, "recurring_count": 320, "resolved_count": 8 }
|
||||
],
|
||||
"total_new": 85,
|
||||
"total_resolved": 42
|
||||
}
|
||||
```
|
||||
|
||||
### Reporting
|
||||
|
||||
**`GET /api/compliance/vcl-multi/stats`**
|
||||
|
||||
Aggregated cross-vertical executive summary.
|
||||
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"stats": {
|
||||
"total_devices": 4200,
|
||||
"compliant": 3800,
|
||||
"non_compliant": 400,
|
||||
"compliance_pct": 90,
|
||||
"target_pct": 95
|
||||
},
|
||||
"donut": {
|
||||
"blocked": { "count": 120, "pct": 30 },
|
||||
"in_progress": { "count": 280, "pct": 70 }
|
||||
},
|
||||
"vertical_breakdown": [
|
||||
{
|
||||
"vertical": "NTS_AEO",
|
||||
"total_devices": 800,
|
||||
"compliant": 720,
|
||||
"non_compliant": 80,
|
||||
"compliance_pct": 90,
|
||||
"blockers": 25,
|
||||
"forecast_burndown": { "2026-06": 20, "2026-07": 35, "2026-08": 15 },
|
||||
"last_upload": "2026-05-11"
|
||||
}
|
||||
],
|
||||
"last_upload_date": "2026-05-11"
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/compliance/vcl-multi/trend`**
|
||||
|
||||
Monthly trend data for the overview chart.
|
||||
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"months": [
|
||||
{
|
||||
"month": "2026-03",
|
||||
"compliance_pct": 85,
|
||||
"compliant": 3400,
|
||||
"non_compliant": 600,
|
||||
"forecast_pct": null,
|
||||
"target_pct": 95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/compliance/vcl-multi/vertical/:code/metrics`**
|
||||
|
||||
Per-metric breakdown for a specific vertical.
|
||||
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"vertical": "NTS_AEO",
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "5.2.4",
|
||||
"metric_desc": "MFA enforcement on privileged accounts",
|
||||
"category": "Access & MFA",
|
||||
"non_compliant": 15,
|
||||
"compliant": 785,
|
||||
"total": 800,
|
||||
"compliance_pct": 98.1,
|
||||
"target": 100
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
{ "category": "Access & MFA", "non_compliant": 45, "compliance_pct": 94.4 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices`**
|
||||
|
||||
Device list for a specific vertical + metric combination.
|
||||
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"hostname": "srv-nts-001",
|
||||
"ip_address": "10.1.2.3",
|
||||
"device_type": "Router",
|
||||
"team": "STEAM",
|
||||
"seen_count": 4,
|
||||
"first_seen": "2026-03-15",
|
||||
"last_seen": "2026-05-11",
|
||||
"resolution_date": "2026-07-01",
|
||||
"remediation_plan": "Scheduled for next maintenance window"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/compliance/vcl-multi/vertical/:code/burndown`**
|
||||
|
||||
Burndown forecast for a specific vertical.
|
||||
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"vertical": "NTS_AEO",
|
||||
"total_non_compliant": 80,
|
||||
"blockers": 25,
|
||||
"with_dates": 55,
|
||||
"monthly_forecast": { "2026-06": 20, "2026-07": 35 },
|
||||
"projected_clear_date": "2026-08"
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### New Page: `VCLMultiVerticalPage.js`
|
||||
|
||||
Top-level page accessible from the nav drawer. Contains:
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `MultiVerticalUploadModal` | Multi-file drag-drop, filename parsing, batch preview, commit |
|
||||
| `VCLMultiStatsBar` | Aggregated stats across all verticals |
|
||||
| `VCLMultiVerticalTable` | Breakdown table with one row per vertical, clickable for drill-down |
|
||||
| `VCLMultiTrendChart` | Monthly compliance trend with forecast line |
|
||||
| `VCLMultiDonutChart` | Blocked vs In-Progress donut |
|
||||
| `VerticalDetailView` | Per-metric breakdown when a vertical is selected |
|
||||
| `MetricDeviceList` | Device list when a metric is selected within a vertical |
|
||||
| `VerticalBurndownChart` | Per-vertical burndown projection |
|
||||
|
||||
### Navigation
|
||||
|
||||
- New entry in `NavDrawer.js`: "VCL Multi-Vertical" (or "CCP Metrics")
|
||||
- Separate from existing "Compliance" and "VCL Report" entries
|
||||
- Icon: `BarChart3` or `Building2` from lucide-react
|
||||
|
||||
### Drill-down UX Flow
|
||||
|
||||
```
|
||||
VCL Multi-Vertical Overview
|
||||
├── Stats Bar (aggregated)
|
||||
├── Trend Chart (aggregated)
|
||||
├── Donut Chart (aggregated)
|
||||
└── Vertical Breakdown Table
|
||||
├── NTS_AEO (90%) → click
|
||||
│ ├── Metric Breakdown
|
||||
│ │ ├── 5.2.4 — Access & MFA (98.1%) → click
|
||||
│ │ │ └── Device List (15 devices)
|
||||
│ │ ├── 1.1.1 — Logging & Monitoring (85%) → click
|
||||
│ │ │ └── Device List (120 devices)
|
||||
│ │ └── ...
|
||||
│ └── Burndown Chart (vertical-specific)
|
||||
├── SDIT_CISO (92%) → click
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Scoped Resolution Logic
|
||||
|
||||
This is the core architectural change from the existing upload flow.
|
||||
|
||||
### Current behavior (single-file)
|
||||
|
||||
```javascript
|
||||
// Resolves ALL active items not in the upload — global scope
|
||||
for (const [key, row] of Object.entries(activeMap)) {
|
||||
if (!newKeys.has(key)) {
|
||||
await client.query(`UPDATE compliance_items SET status = 'resolved' WHERE id = $1`, [row.id]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### New behavior (multi-vertical)
|
||||
|
||||
```javascript
|
||||
// Resolves only items within the same vertical — scoped
|
||||
const { rows: activeRows } = await client.query(
|
||||
`SELECT id, hostname, metric_id, seen_count FROM compliance_items
|
||||
WHERE status = 'active' AND vertical = $1`,
|
||||
[vertical]
|
||||
);
|
||||
|
||||
for (const [key, row] of Object.entries(activeMap)) {
|
||||
if (!newKeys.has(key)) {
|
||||
await client.query(`UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $1 WHERE id = $2`, [uploadId, row.id]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The only difference is the `AND vertical = $1` filter on the active items query. This ensures uploading NTS_AEO data never touches SDIT_CISO items.
|
||||
|
||||
## Burndown Forecast Computation
|
||||
|
||||
### Per-vertical burndown
|
||||
|
||||
For each vertical, the burndown is computed from `resolution_date` values on active non-compliant items:
|
||||
|
||||
```javascript
|
||||
function computeVerticalBurndown(items) {
|
||||
const total = items.length;
|
||||
const withDates = items.filter(i => i.resolution_date != null);
|
||||
const blockers = items.filter(i => i.resolution_date == null);
|
||||
|
||||
// Bucket by month
|
||||
const monthly = {};
|
||||
for (const item of withDates) {
|
||||
const month = item.resolution_date.slice(0, 7); // YYYY-MM
|
||||
monthly[month] = (monthly[month] || 0) + 1;
|
||||
}
|
||||
|
||||
// Cumulative projection
|
||||
let remaining = total;
|
||||
const projection = {};
|
||||
for (const month of Object.keys(monthly).sort()) {
|
||||
remaining -= monthly[month];
|
||||
projection[month] = { remediated: monthly[month], remaining };
|
||||
}
|
||||
|
||||
return { total, blockers: blockers.length, with_dates: withDates.length, monthly, projection };
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregated trend forecast
|
||||
|
||||
The trend chart forecast uses linear regression on the last 3+ monthly snapshots to project forward. This reuses the same approach as the existing VCL trend endpoint.
|
||||
|
||||
## Migration Script
|
||||
|
||||
```javascript
|
||||
// backend/migrations/add_vcl_multi_vertical.js
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting VCL multi-vertical migration...');
|
||||
try {
|
||||
// Add vertical column to compliance_items
|
||||
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_items');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
||||
console.log('✓ idx_compliance_items_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
||||
console.log('✓ idx_compliance_items_vertical_status index created');
|
||||
|
||||
// Add vertical column to compliance_uploads
|
||||
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_uploads');
|
||||
|
||||
// Create summary table for per-vertical metric data
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||
id SERIAL PRIMARY KEY,
|
||||
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
vertical TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT DEFAULT '',
|
||||
category TEXT DEFAULT 'Other',
|
||||
team TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT '',
|
||||
non_compliant INTEGER DEFAULT 0,
|
||||
compliant INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0,
|
||||
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||
target NUMERIC(5,2) DEFAULT 0,
|
||||
status TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ vcl_multi_vertical_summary table created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
||||
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
||||
console.log('✓ idx_vcl_multi_summary_upload index created');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Migration error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
```
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
### Property 1: Vertical-Scoped Resolution Isolation
|
||||
|
||||
*For any* set of active compliance items across N verticals, committing an upload for vertical X must only resolve items where `vertical = X`. The count of active items for all other verticals must remain unchanged before and after the commit.
|
||||
|
||||
### Property 2: Filename Parsing Completeness
|
||||
|
||||
*For any* filename matching the pattern `<VERTICAL>_YYYY_MM_DD.xlsx` where VERTICAL contains only alphanumeric characters and underscores, `parseVerticalFilename` must return a non-null result with the correct vertical code and ISO date string.
|
||||
|
||||
### Property 3: Aggregated Stats Consistency
|
||||
|
||||
*For any* set of per-vertical stats, the aggregated `total_devices` must equal the sum of all vertical `total_devices`, `compliant` must equal the sum of all vertical `compliant`, and `compliance_pct` must equal `Math.round((sum_compliant / sum_total) * 100)`.
|
||||
|
||||
### Property 4: Burndown Forecast Conservation
|
||||
|
||||
*For any* set of non-compliant items with resolution dates, the sum of all monthly burndown bucket counts must equal the count of items with non-null resolution dates. No item is double-counted or lost.
|
||||
|
||||
### Property 5: Idempotent Re-upload
|
||||
|
||||
*For any* vertical, uploading the same file twice on the same day must produce the same final state as uploading it once. Specifically: same active item set, same seen_counts, same resolved set.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| Filename doesn't match pattern | File flagged as "unrecognized" in preview; user can assign vertical manually |
|
||||
| Duplicate vertical in batch | Reject — only one file per vertical per batch |
|
||||
| Parser failure on one file | That file is marked as errored; other files in batch can still proceed |
|
||||
| Transaction failure during commit | Full rollback of entire batch — no partial commits |
|
||||
| File exceeds 10MB | Rejected by multer before parsing |
|
||||
| No items parsed from file | Warning in preview; user can still commit (creates upload record with 0 items) |
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
- The feature is self-contained behind `/api/compliance/vcl-multi/` endpoints
|
||||
- Can be deployed on a separate instance with its own database
|
||||
- No changes to existing AEO compliance upload flow
|
||||
- Feature flag not needed — the nav entry and endpoints simply exist or don't
|
||||
- Environment variable `VCL_TARGET_PCT` (default 95) applies to multi-vertical reporting as well
|
||||
115
.kiro/specs/vcl-multi-vertical-upload/requirements.md
Normal file
115
.kiro/specs/vcl-multi-vertical-upload/requirements.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Requirements: VCL Multi-Vertical Upload
|
||||
|
||||
## Context
|
||||
|
||||
The compliance team generates CCP (Customer Compliance Program) metric data from CyberMetrics on a 24-hour cycle. The data is exported as separate xlsx files per organizational vertical (e.g., NTS_AEO, SDIT_CISO, TSI). They need a way to upload these files into the VCL reporting page to generate executive-level compliance reports for senior leadership across all organizations — with the ability to drill down by vertical and by metric.
|
||||
|
||||
This is a POC that may later be replaced by direct API integration with CyberMetrics. It will be deployed as a separate flow from the existing single-file AEO compliance upload, and may run on its own instance to isolate compliance team experimentation from dev/production data.
|
||||
|
||||
## Verticals (from filename convention)
|
||||
|
||||
| Vertical Code | Organization |
|
||||
|---|---|
|
||||
| AllOthers | Catch-all for unclassified |
|
||||
| NTS_AEO | NTS AEO (contains sub-teams: STEAM, ACCESS-ENG, ACCESS-OPS) |
|
||||
| NTS_AVVOC | NTS AVVOC |
|
||||
| NTS_CPE | NTS CPE |
|
||||
| NTS_NEO | NTS NEO |
|
||||
| NTS_WTS | NTS WTS |
|
||||
| PRDCT_VSO | Product VSO |
|
||||
| SBNOE | SBNOE |
|
||||
| SDIT_CISO | SDIT CISO |
|
||||
| SDIT_CSD | SDIT CSD |
|
||||
| SDIT_EDIS | SDIT EDIS |
|
||||
| SDIT_IT | SDIT IT |
|
||||
| SR | SR |
|
||||
| TSI | TSI |
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Multi-file upload
|
||||
|
||||
As a compliance analyst, I want to upload multiple vertical xlsx files at once so that I can ingest a full reporting cycle without uploading one file at a time.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 1.1: User can select or drag-drop 1–14 xlsx files simultaneously
|
||||
- 1.2: System extracts the vertical code from each filename using the pattern `<VERTICAL>_YYYY_MM_DD.xlsx`
|
||||
- 1.3: System extracts the report date from each filename
|
||||
- 1.4: If a filename does not match the expected pattern, the user is prompted to manually assign a vertical and date
|
||||
- 1.5: A preview table shows: filename, detected vertical, report date, item count, diff (new/recurring/resolved) per file
|
||||
- 1.6: User can remove individual files from the batch before committing
|
||||
- 1.7: User confirms the batch and all files are committed
|
||||
- 1.8: Upload supports daily frequency (not just weekly)
|
||||
|
||||
### US-2: Vertical-scoped resolution
|
||||
|
||||
As a compliance analyst, I want uploading a vertical's file to only affect that vertical's data so that partial uploads don't incorrectly resolve devices from other verticals.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 2.1: Committing a file for vertical X only resolves active items where `vertical = X`
|
||||
- 2.2: Items belonging to other verticals remain unchanged
|
||||
- 2.3: Re-uploading the same vertical on the same day replaces the previous state (idempotent)
|
||||
- 2.4: The system tracks which upload introduced/resolved each item per vertical
|
||||
|
||||
### US-3: Cross-vertical VCL report
|
||||
|
||||
As a senior leader, I want to see an aggregated compliance report across all verticals so that I can assess organizational posture at a glance.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 3.1: Stats bar shows aggregated totals: Total Devices, Compliant, Non-Compliant, Current %, Target %
|
||||
- 3.2: Vertical breakdown table shows one row per vertical with: compliance %, non-compliant count, total devices
|
||||
- 3.3: Donut chart shows Blocked vs In-Progress across all verticals
|
||||
- 3.4: Trend chart shows monthly compliance % over time (aggregated)
|
||||
- 3.5: Data refreshes immediately after a new upload is committed
|
||||
|
||||
### US-4: Vertical drill-down
|
||||
|
||||
As a compliance analyst, I want to click into a vertical to see its per-metric breakdown so that I can identify which metrics are driving non-compliance.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 4.1: Clicking a vertical row navigates to a detail view for that vertical
|
||||
- 4.2: Detail view shows per-metric compliance: metric ID, description, compliant count, non-compliant count, compliance %, target %
|
||||
- 4.3: Metrics are grouped by category (Logging & Monitoring, Vulnerability Management, etc.)
|
||||
- 4.4: Each metric row is clickable to see the device list for that metric
|
||||
|
||||
### US-5: Metric drill-down
|
||||
|
||||
As a compliance analyst, I want to click a metric within a vertical to see the non-compliant devices so that I can identify specific remediation targets.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 5.1: Clicking a metric shows the list of non-compliant devices for that metric in that vertical
|
||||
- 5.2: Device list shows: hostname, IP address, device type, team (sub-team), seen count, first seen, last seen
|
||||
- 5.3: Devices can have resolution dates set (for burndown forecasting)
|
||||
- 5.4: Devices can have remediation plans documented
|
||||
|
||||
### US-6: Burndown forecast
|
||||
|
||||
As a senior leader, I want to see a projected burndown timeline so that I can assess whether verticals are on track to meet compliance targets.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 6.1: Each vertical in the breakdown table shows forecast burndown columns (monthly projections)
|
||||
- 6.2: Burndown is computed from resolution_date values on non-compliant devices
|
||||
- 6.3: Devices without a resolution_date count as "blockers" (no committed timeline)
|
||||
- 6.4: The trend chart includes a forecast line (linear regression on 3+ months of data)
|
||||
- 6.5: Per-vertical drill-down shows that vertical's burndown separately
|
||||
|
||||
### US-7: Separation from existing AEO upload
|
||||
|
||||
As a system administrator, I want the multi-vertical upload to be a separate flow from the existing AEO compliance upload so that the two don't interfere with each other.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 7.1: Multi-vertical upload has its own UI entry point (separate from the existing "Upload" button on the AEO Compliance page)
|
||||
- 7.2: Multi-vertical data is stored with a `vertical` field that distinguishes it from existing AEO-only data
|
||||
- 7.3: Existing AEO compliance page continues to work unchanged
|
||||
- 7.4: The VCL report page can show either multi-vertical data or fall back to existing AEO-only data if no multi-vertical uploads exist
|
||||
- 7.5: The system can be deployed as a standalone instance without affecting other deployments
|
||||
|
||||
### US-8: Summary sheet ingestion
|
||||
|
||||
As a compliance analyst, I want the system to parse the Summary sheet from each vertical file so that metric-level health data (compliance %, targets) is captured per vertical.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- 8.1: Summary sheet data is stored per vertical per upload
|
||||
- 8.2: Overall scores (customer_network, vertical) are captured
|
||||
- 8.3: Per-metric entries include: metric_id, non_compliant, compliant, total, compliance_pct, target, status, description
|
||||
- 8.4: Summary data feeds the metric drill-down view (compliance % and targets come from here)
|
||||
169
.kiro/specs/vcl-multi-vertical-upload/tasks.md
Normal file
169
.kiro/specs/vcl-multi-vertical-upload/tasks.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Tasks: VCL Multi-Vertical Upload
|
||||
|
||||
## Phase 1: Database & Backend Foundation
|
||||
|
||||
- [x] 1. Create migration script `backend/migrations/add_vcl_multi_vertical.js`
|
||||
- Add `vertical` column to `compliance_items` (TEXT, nullable, indexed)
|
||||
- Add `vertical` column to `compliance_uploads` (TEXT, nullable)
|
||||
- Create `vcl_multi_vertical_summary` table
|
||||
- Create indexes for vertical-based queries
|
||||
|
||||
- [x] 2. Add `parseVerticalFilename()` helper to `backend/helpers/vclHelpers.js`
|
||||
- Extract vertical code and report date from filename pattern
|
||||
- Handle edge cases (no match, single-word verticals like "SR")
|
||||
- Export for testing
|
||||
|
||||
- [x] 3. Implement vertical-scoped `persistMultiVerticalUpload()` in compliance route
|
||||
- Accept items + vertical + summary + metadata
|
||||
- Query active items filtered by `WHERE vertical = $1`
|
||||
- Upsert new/recurring items with vertical tag
|
||||
- Resolve only items within the same vertical
|
||||
- Create/update compliance_snapshots for the vertical
|
||||
- Store summary entries in `vcl_multi_vertical_summary`
|
||||
|
||||
- [x] 4. Implement `POST /api/compliance/vcl-multi/preview` endpoint
|
||||
- Accept multiple files via multer `.array('files', 14)`
|
||||
- Parse each file with existing Python parser
|
||||
- Extract vertical from filename for each
|
||||
- Compute per-vertical scoped diff
|
||||
- Store parsed data in temp files
|
||||
- Return batch preview response
|
||||
|
||||
- [x] 5. Implement `POST /api/compliance/vcl-multi/commit` endpoint
|
||||
- Read temp files for each file in batch
|
||||
- Commit all in a single transaction using `persistMultiVerticalUpload()`
|
||||
- Rollback entire batch on any failure
|
||||
- Clean up temp files
|
||||
- Audit log the batch commit
|
||||
|
||||
## Phase 2: Reporting Endpoints
|
||||
|
||||
- [x] 6. Implement `GET /api/compliance/vcl-multi/stats` endpoint
|
||||
- Aggregate across all verticals where `vertical IS NOT NULL`
|
||||
- Compute total/compliant/non-compliant/compliance_pct
|
||||
- Compute donut (blocked vs in-progress)
|
||||
- Compute per-vertical breakdown with burndown
|
||||
- Return structured response
|
||||
|
||||
- [x] 7. Implement `GET /api/compliance/vcl-multi/trend` endpoint
|
||||
- Query compliance_snapshots for multi-vertical data
|
||||
- Aggregate monthly compliance % across verticals
|
||||
- Compute linear regression forecast (3+ months)
|
||||
- Return monthly data points
|
||||
|
||||
- [x] 8. Implement `GET /api/compliance/vcl-multi/vertical/:code/metrics` endpoint
|
||||
- Query `vcl_multi_vertical_summary` for latest upload of that vertical
|
||||
- Group by category
|
||||
- Return per-metric breakdown
|
||||
|
||||
- [x] 9. Implement `GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` endpoint
|
||||
- Query `compliance_items` filtered by vertical + metric_id + status = 'active'
|
||||
- Include resolution_date, remediation_plan, seen_count, first/last seen
|
||||
- Return device list
|
||||
|
||||
- [x] 10. Implement `GET /api/compliance/vcl-multi/vertical/:code/burndown` endpoint
|
||||
- Query non-compliant items for vertical
|
||||
- Compute monthly forecast from resolution_date values
|
||||
- Return burndown data with blocker count
|
||||
|
||||
## Phase 3: Frontend — Upload Modal
|
||||
|
||||
- [x] 11. Create `MultiVerticalUploadModal.js` component
|
||||
- Multi-file drag-drop zone (accept .xlsx, max 14 files)
|
||||
- Filename parsing with vertical/date extraction on selection
|
||||
- Display file list with detected vertical, date, status
|
||||
- Allow removing individual files from batch
|
||||
- Handle unrecognized filenames (manual vertical assignment)
|
||||
|
||||
- [x] 12. Implement preview phase in upload modal
|
||||
- Call POST /preview with all files
|
||||
- Display batch preview table: filename, vertical, items, diff
|
||||
- Show totals row (total new, total recurring, total resolved)
|
||||
- Error display for files that failed parsing
|
||||
|
||||
- [x] 13. Implement commit phase in upload modal
|
||||
- Confirm button triggers POST /commit
|
||||
- Loading state during commit
|
||||
- Success state with summary of what was committed
|
||||
- Error state with rollback messaging
|
||||
|
||||
## Phase 4: Frontend — Report Page
|
||||
|
||||
- [x] 14. Create `VCLMultiVerticalPage.js` page component (named CCPMetricsPage.js)
|
||||
- Add to NavDrawer with appropriate icon
|
||||
- Page layout: stats bar, charts row, vertical table
|
||||
- Fetch data from /vcl-multi/stats on mount
|
||||
- Loading and empty states
|
||||
|
||||
- [x] 15. Implement `VCLMultiStatsBar` component
|
||||
- Total Devices, Compliant, Non-Compliant, Current %, Target %
|
||||
- Match existing VCL stats bar styling
|
||||
|
||||
- [x] 16. Implement `VCLMultiVerticalTable` component
|
||||
- One row per vertical: name, compliance %, non-compliant, total, last upload date
|
||||
- Sortable columns
|
||||
- Click row to drill down
|
||||
- Burndown forecast columns (monthly)
|
||||
- Blockers column
|
||||
|
||||
- [x] 17. Implement `VCLMultiTrendChart` component (recharts)
|
||||
- Monthly bars for compliant count
|
||||
- Solid line for actual compliance %
|
||||
- Dashed line for forecast %
|
||||
- Reference line for target %
|
||||
- Match existing chart styling
|
||||
|
||||
- [x] 18. Implement `VCLMultiDonutChart` component (recharts)
|
||||
- Blocked vs In-Progress segments
|
||||
- Center label with total non-compliant
|
||||
- Match existing donut styling
|
||||
|
||||
## Phase 5: Frontend — Drill-Down Views
|
||||
|
||||
- [x] 19. Implement `VerticalDetailView` component
|
||||
- Triggered when a vertical row is clicked
|
||||
- Fetch /vertical/:code/metrics
|
||||
- Display per-metric table grouped by category
|
||||
- Click metric to drill further
|
||||
- Back button to return to overview
|
||||
|
||||
- [x] 20. Implement `MetricDeviceList` component
|
||||
- Triggered when a metric row is clicked
|
||||
- Fetch /vertical/:code/metric/:metricId/devices
|
||||
- Display device table: hostname, IP, type, team, seen_count, dates
|
||||
- Resolution date inline editing
|
||||
- Back button to return to metric view
|
||||
|
||||
- [x] 21. Implement `VerticalBurndownChart` component
|
||||
- Displayed in VerticalDetailView
|
||||
- Fetch /vertical/:code/burndown
|
||||
- Bar chart: monthly remediation projections
|
||||
- Annotation for blockers count
|
||||
- Projected clear date label
|
||||
|
||||
## Phase 6: Testing & Documentation
|
||||
|
||||
- [ ] 22. Write property-based tests for new helpers
|
||||
- `parseVerticalFilename` — pattern matching correctness
|
||||
- Vertical-scoped resolution isolation
|
||||
- Aggregated stats consistency
|
||||
- Burndown forecast conservation
|
||||
|
||||
- [ ] 23. Write unit tests for new endpoints
|
||||
- Preview with valid/invalid files
|
||||
- Commit with scoped resolution verification
|
||||
- Stats aggregation with multiple verticals
|
||||
- Drill-down queries
|
||||
|
||||
- [ ] 24. Update README.md
|
||||
- Add VCL Multi-Vertical section to Features
|
||||
- Add new migration to Migrations list
|
||||
- Add new endpoints to API Reference
|
||||
- Add new env vars if any
|
||||
|
||||
- [x] 25. Create meeting-ready design brief document
|
||||
- Architectural choices and rationale
|
||||
- Drill-down hierarchy diagram
|
||||
- Burndown forecast explanation
|
||||
- Open questions for stakeholders
|
||||
- Timeline estimate
|
||||
1
.kiro/specs/vendor-issue-type-dropdown/.config.kiro
Normal file
1
.kiro/specs/vendor-issue-type-dropdown/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "02adddd3-ffc0-4935-95f2-7f8d89b9c1ff", "workflowType": "requirements-first", "specType": "feature"}
|
||||
295
.kiro/specs/vendor-issue-type-dropdown/design.md
Normal file
295
.kiro/specs/vendor-issue-type-dropdown/design.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Design Document: Vendor Issue Type Dropdown
|
||||
|
||||
## Overview
|
||||
|
||||
This feature makes the Issue Type dropdown in the Jira ticket Creation Modal context-aware. When a user enters a project key that belongs to a known vendor project (e.g., `AA_VECIMA`), the dropdown switches from STEAM issue types to vendor-specific issue types. The determination is purely frontend logic — a function that compares the trimmed, uppercased project key against a constant array of vendor keys. The backend remains agnostic to issue type values, passing any non-empty string through to the Jira API unchanged.
|
||||
|
||||
The design prioritizes single-source-of-truth configuration: one constant array for vendor keys, one for vendor issue types, one for STEAM issue types. Both the JiraPage modal and the Ivanti queue modal reference the same constants and the same determination logic.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Frontend Constants
|
||||
VPK[VENDOR_PROJECT_KEYS<br/>e.g. 'AA_VECIMA']
|
||||
VIT[VENDOR_ISSUE_TYPES<br/>Epic, Story, Task, ...]
|
||||
SIT[STEAM_ISSUE_TYPES<br/>Story, Epic, Program, ...]
|
||||
end
|
||||
|
||||
subgraph Determination Logic
|
||||
FN["getIssueTypes(projectKey, vendorKeys)"]
|
||||
end
|
||||
|
||||
subgraph Creation Modals
|
||||
JM[JiraPage Modal]
|
||||
IQ[Ivanti Queue Modal]
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
EP["POST /api/jira-tickets/create-in-jira"]
|
||||
JA[Jira REST API]
|
||||
end
|
||||
|
||||
VPK --> FN
|
||||
VIT --> FN
|
||||
SIT --> FN
|
||||
FN --> JM
|
||||
FN --> IQ
|
||||
JM -->|"{ issue_type: string }"| EP
|
||||
IQ -->|"{ issue_type: string }"| EP
|
||||
EP -->|"issuetype: { name: value }"| JA
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
1. **Pure function for determination** — The logic that decides which issue type list to show is a pure function (`getIssueTypes`) that takes the current project key string and returns the appropriate array. This makes it testable independently of React rendering.
|
||||
|
||||
2. **Constants at module level** — All three arrays (`VENDOR_PROJECT_KEYS`, `VENDOR_ISSUE_TYPES`, `STEAM_ISSUE_TYPES`) are defined as module-level constants in a shared location importable by both JiraPage and IvantiTodoQueuePage.
|
||||
|
||||
3. **Backend is type-agnostic** — The backend does not maintain a list of valid issue types. It passes whatever `issue_type` string it receives to the Jira API. If Jira rejects it, the error propagates back to the user.
|
||||
|
||||
4. **Reset on context switch only** — The selected issue type resets to empty only when the project context changes (STEAM ↔ vendor). Edits that stay within the same context preserve the selection.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Frontend: Issue Type Constants
|
||||
|
||||
A new constants file or a constants block at the top of JiraPage.js (adjacent to existing STYLES):
|
||||
|
||||
```javascript
|
||||
// Vendor project keys — add new vendor keys here to enable vendor issue types
|
||||
const VENDOR_PROJECT_KEYS = ['AA_VECIMA'];
|
||||
|
||||
// Issue types for vendor projects (e.g., EoN-VECIMA)
|
||||
const VENDOR_ISSUE_TYPES = [
|
||||
'Epic',
|
||||
'Story',
|
||||
'Task',
|
||||
'Defect',
|
||||
'Production Defect/Incident Fix',
|
||||
'New Feature',
|
||||
'Spike',
|
||||
'Release Candidate',
|
||||
'Documentation',
|
||||
];
|
||||
|
||||
// Issue types for STEAM projects (default)
|
||||
const STEAM_ISSUE_TYPES = [
|
||||
'Story',
|
||||
'Epic',
|
||||
'Program',
|
||||
'Project',
|
||||
'Reservation',
|
||||
'Automation Maintenance',
|
||||
];
|
||||
```
|
||||
|
||||
### Frontend: Determination Function
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Determines whether a project key belongs to a vendor project.
|
||||
* @param {string} projectKey - The raw project key input value
|
||||
* @param {string[]} vendorKeys - Array of known vendor project keys (uppercase)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isVendorProject(projectKey, vendorKeys) {
|
||||
if (!projectKey || typeof projectKey !== 'string') return false;
|
||||
const normalized = projectKey.trim().toUpperCase();
|
||||
if (normalized.length === 0) return false;
|
||||
return vendorKeys.includes(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate issue type list for a given project key.
|
||||
* @param {string} projectKey - The raw project key input value
|
||||
* @param {string[]} vendorKeys - Array of known vendor project keys
|
||||
* @param {string[]} vendorTypes - Vendor issue type options
|
||||
* @param {string[]} steamTypes - STEAM issue type options
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
||||
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend: JiraPage Modal Integration
|
||||
|
||||
The existing `createJiraForm.project_key` onChange handler is extended to:
|
||||
|
||||
1. Call `isVendorProject()` on the new value and the old value.
|
||||
2. If the context changed (vendor → STEAM or STEAM → vendor), reset `issue_type` to `''`.
|
||||
3. If the context did not change, preserve the current `issue_type` selection.
|
||||
|
||||
The `<select>` for Issue Type renders options dynamically based on `getIssueTypesForProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES)`.
|
||||
|
||||
```javascript
|
||||
// In the project_key onChange handler:
|
||||
onChange={e => {
|
||||
const newKey = e.target.value.toUpperCase();
|
||||
const wasVendor = isVendorProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS);
|
||||
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
|
||||
setCreateJiraForm(f => ({
|
||||
...f,
|
||||
project_key: newKey,
|
||||
// Reset issue_type only when context switches
|
||||
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
|
||||
}));
|
||||
}}
|
||||
```
|
||||
|
||||
The Issue Type dropdown renders dynamically:
|
||||
|
||||
```javascript
|
||||
// Compute current issue types
|
||||
const currentIssueTypes = getIssueTypesForProject(
|
||||
createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES
|
||||
);
|
||||
|
||||
// In JSX:
|
||||
<select value={createJiraForm.issue_type} onChange={...}>
|
||||
<option value="">Story (default)</option>
|
||||
{currentIssueTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
```
|
||||
|
||||
### Frontend: Ivanti Queue Modal Integration
|
||||
|
||||
The Ivanti queue single-item modal (`showSingleJiraModal`) gains a Project Key input and an Issue Type dropdown that use the same constants and determination logic. The `singleJiraForm` state is extended with `project_key` and `issue_type` fields.
|
||||
|
||||
If the modal is opened with a pre-populated `project_key` that matches a vendor key, the issue type dropdown shows vendor options on initial render.
|
||||
|
||||
### Backend: No Changes Required
|
||||
|
||||
The existing `POST /api/jira-tickets/create-in-jira` endpoint already handles `issue_type` as a passthrough:
|
||||
|
||||
```javascript
|
||||
const issueType = issue_type || jiraApi.JIRA_ISSUE_TYPE;
|
||||
// ...
|
||||
fields.issuetype = { name: issueType };
|
||||
```
|
||||
|
||||
This logic already:
|
||||
- Falls back to `JIRA_ISSUE_TYPE` env var when `issue_type` is empty/null/undefined
|
||||
- Passes any non-empty string directly to the Jira API
|
||||
- Does not validate against a fixed list
|
||||
|
||||
No backend code changes are needed. The backend is already compliant with Requirements 7.1–7.4.
|
||||
|
||||
## Data Models
|
||||
|
||||
No database schema changes are required. The `issue_type` value is not stored in the local `jira_tickets` table — it is only used at creation time to set the Jira issue's type via the API.
|
||||
|
||||
**Frontend state model (extended):**
|
||||
|
||||
```javascript
|
||||
// createJiraForm state shape (JiraPage)
|
||||
{
|
||||
cve_id: string,
|
||||
vendor: string,
|
||||
summary: string,
|
||||
description: string,
|
||||
project_key: string, // existing — used for vendor determination
|
||||
issue_type: string, // existing — now dynamically constrained
|
||||
source_context: string,
|
||||
}
|
||||
|
||||
// singleJiraForm state shape (IvantiTodoQueuePage) — extended
|
||||
{
|
||||
cve_id: string,
|
||||
vendor: string,
|
||||
summary: string,
|
||||
description: string,
|
||||
source_context: 'ivanti_queue',
|
||||
project_key: string, // NEW — enables vendor key entry
|
||||
issue_type: string, // NEW — context-aware dropdown
|
||||
}
|
||||
```
|
||||
|
||||
## 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.*
|
||||
|
||||
### Property 1: Issue type list determination
|
||||
|
||||
*For any* string input as a project key, after trimming whitespace and converting to uppercase, if the result exactly matches an entry in the VENDOR_PROJECT_KEYS array then `getIssueTypesForProject` SHALL return the VENDOR_ISSUE_TYPES array; otherwise it SHALL return the STEAM_ISSUE_TYPES array.
|
||||
|
||||
**Validates: Requirements 1.2, 1.3, 1.4, 2.1, 2.3, 3.4, 3.5**
|
||||
|
||||
### Property 2: Context switch resets issue type selection
|
||||
|
||||
*For any* pair of project key values where one resolves to vendor context and the other resolves to STEAM context (or vice versa), changing the project key from one to the other SHALL result in the issue_type being reset to an empty string.
|
||||
|
||||
**Validates: Requirements 1.5, 4.2, 6.3**
|
||||
|
||||
### Property 3: Same context preserves issue type selection
|
||||
|
||||
*For any* pair of project key values that both resolve to the same context (both vendor or both STEAM), and any previously selected issue_type value, changing the project key from one to the other SHALL preserve the issue_type value unchanged.
|
||||
|
||||
**Validates: Requirements 4.1**
|
||||
|
||||
### Property 4: Backend issue type passthrough
|
||||
|
||||
*For any* non-empty string of 1 to 100 characters provided as `issue_type` in a create-ticket request, the backend SHALL pass that exact string to the Jira API as the `issuetype.name` field without modification or validation against a fixed list.
|
||||
|
||||
**Validates: Requirements 7.1, 7.3**
|
||||
|
||||
### Property 5: Backend issue type fallback to environment variable
|
||||
|
||||
*For any* create-ticket request where `issue_type` is undefined, null, or an empty string, the backend SHALL use the value of the `JIRA_ISSUE_TYPE` environment variable as the `issuetype.name` field sent to the Jira API.
|
||||
|
||||
**Validates: Requirements 7.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior | User Experience |
|
||||
|---|---|---|
|
||||
| User enters a project key not in vendor list | Dropdown shows STEAM types | Seamless — no error state |
|
||||
| User enters a vendor key with mixed casing | Normalized to uppercase, matched correctly | Seamless — input auto-uppercases |
|
||||
| User switches context with a type selected | Selection resets to empty (default) | Dropdown shows "Story (default)" placeholder |
|
||||
| Jira API rejects the issue type for the target project | Backend returns HTTP 502 with Jira error details | Modal displays error message, form preserved for retry |
|
||||
| VENDOR_PROJECT_KEYS is empty array | All keys resolve to STEAM types | Graceful degradation — behaves as before this feature |
|
||||
| Project key is only whitespace | Trimmed to empty → STEAM types shown | Same as empty input |
|
||||
|
||||
No new HTTP error codes or validation errors are introduced by this feature. The backend's existing passthrough behavior means invalid issue types are caught by the Jira API, not by our validation layer.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
- `isVendorProject('AA_VECIMA', ['AA_VECIMA'])` returns `true`
|
||||
- `isVendorProject('STEAM-123', ['AA_VECIMA'])` returns `false`
|
||||
- `isVendorProject('', ['AA_VECIMA'])` returns `false`
|
||||
- `isVendorProject(null, ['AA_VECIMA'])` returns `false`
|
||||
- Dropdown renders vendor issue types when project key is `AA_VECIMA`
|
||||
- Dropdown renders STEAM issue types when project key is empty
|
||||
- "Story (default)" placeholder option has empty string value in both contexts
|
||||
- Ivanti queue modal shows project_key and issue_type fields
|
||||
- Ivanti queue modal with pre-populated vendor key shows vendor types on mount
|
||||
- Backend returns 502 when Jira API rejects issue type (mocked)
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based testing is appropriate for this feature because the determination logic is a pure function operating on arbitrary string inputs where universal properties must hold across the entire input space (any possible project key string).
|
||||
|
||||
**Library:** fast-check (already available in the project's test infrastructure via Jest)
|
||||
|
||||
**Configuration:**
|
||||
- Minimum 100 iterations per property test
|
||||
- Each test tagged with: `Feature: vendor-issue-type-dropdown, Property {N}: {title}`
|
||||
|
||||
| Property | What it tests | Approach |
|
||||
|---|---|---|
|
||||
| 1: Issue type list determination | Pure function correctness | Generate random strings; verify output matches expected array based on membership in vendor keys |
|
||||
| 2: Context switch resets selection | State transition logic | Generate vendor/non-vendor key pairs; simulate transition; verify reset |
|
||||
| 3: Same context preserves selection | State preservation logic | Generate same-context key pairs + arbitrary selection; simulate transition; verify preservation |
|
||||
| 4: Backend issue type passthrough | Backend field forwarding | Generate random 1-100 char strings; mock Jira API; verify exact string passed through |
|
||||
| 5: Backend issue type fallback | Default value logic | Generate undefined/null/empty variants; mock Jira API; verify env var used |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end: enter `AA_VECIMA` as project key, select "Defect", submit → verify Jira API receives `issuetype: { name: "Defect" }`
|
||||
- End-to-end: enter `AA_VECIMA` then clear it → verify dropdown switches back to STEAM types and selection resets
|
||||
- Ivanti queue: open modal, enter vendor key, select vendor type, submit → verify correct issue type in payload
|
||||
94
.kiro/specs/vendor-issue-type-dropdown/requirements.md
Normal file
94
.kiro/specs/vendor-issue-type-dropdown/requirements.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The STEAM Security Dashboard's Jira ticket creation modal includes an "Issue Type" dropdown that currently only lists STEAM project issue types (Story, Epic, Program, Project, Reservation, Automation Maintenance). When users create tickets in vendor Jira projects (e.g., EoN-VECIMA / AA_VECIMA), those projects have a different set of issue types (Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation). This feature adds context-aware issue type selection so that the dropdown options change based on the target project key entered in the creation modal.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Creation_Modal**: The frontend modal dialog in JiraPage.js used to create a new Jira ticket.
|
||||
- **Issue_Type_Dropdown**: The select element within the Creation_Modal that allows users to choose the Jira issue type for the ticket being created.
|
||||
- **Project_Key_Input**: The text input field in the Creation_Modal where users can override the default JIRA_PROJECT_KEY from the .env file.
|
||||
- **STEAM_Project**: The default internal Jira project identified by the JIRA_PROJECT_KEY environment variable, which uses issue types: Story, Epic, Program, Project, Reservation, Automation Maintenance.
|
||||
- **Vendor_Project**: A Jira project belonging to an external vendor (e.g., AA_VECIMA), which uses issue types: Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation.
|
||||
- **Vendor_Project_Keys**: A configurable list of project keys that are recognized as vendor projects, stored as a frontend constant or configuration.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Display Vendor Issue Types for Vendor Project Keys
|
||||
|
||||
**User Story:** As a security analyst, I want the Issue Type dropdown to show vendor-specific issue types when I enter a vendor project key, so that I can select the correct issue type for the target Jira project.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL define a Vendor_Project_Keys list as a frontend constant array of project key strings that identify vendor Jira projects, configurable without a code deployment by updating the list in a single source location.
|
||||
2. WHEN the Project_Key_Input value, after trimming and converting to uppercase, matches a value in the Vendor_Project_Keys list (case-insensitive comparison), THE Issue_Type_Dropdown SHALL display the vendor issue type options: Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation.
|
||||
3. WHEN the Project_Key_Input value, after trimming and converting to uppercase, does not match any value in the Vendor_Project_Keys list, THE Issue_Type_Dropdown SHALL display the STEAM issue type options: Story, Epic, Program, Project, Reservation, Automation Maintenance.
|
||||
4. WHEN the Project_Key_Input is empty (indicating the .env default will be used), THE Issue_Type_Dropdown SHALL display the STEAM issue type options.
|
||||
5. WHEN the Project_Key_Input value changes such that the displayed issue type list switches between vendor and STEAM options, THE Issue_Type_Dropdown SHALL update to display the new option set and reset the selected issue_type value to empty within the same event cycle as the input change.
|
||||
6. THE Issue_Type_Dropdown SHALL re-evaluate which option set to display on every change to the Project_Key_Input value (i.e., on each input change event, not on blur or after a delay).
|
||||
|
||||
### Requirement 2: Case-Insensitive Project Key Matching
|
||||
|
||||
**User Story:** As a security analyst, I want the project key matching to be case-insensitive, so that I do not need to remember the exact casing of vendor project keys.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user enters a project key in any letter casing (e.g., "aa_vecima", "AA_VECIMA", "Aa_Vecima"), THE Issue_Type_Dropdown SHALL match the entered value against the Vendor_Project_Keys list using case-insensitive exact string comparison, where the entire entered value must equal a list entry after both are normalized to the same case.
|
||||
2. THE Project_Key_Input SHALL convert the entered value to uppercase on each keystroke for display purposes, consistent with the existing toUpperCase() behavior in the onChange handler.
|
||||
3. IF the Project_Key_Input contains leading or trailing whitespace, THEN THE Issue_Type_Dropdown SHALL trim the value before performing the case-insensitive comparison against the Vendor_Project_Keys list.
|
||||
|
||||
### Requirement 3: Configurable Vendor Project Key List
|
||||
|
||||
**User Story:** As a developer, I want the list of vendor project keys to be defined in a single location, so that adding new vendor projects in the future requires a change in only one place.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Creation_Modal SHALL define the Vendor_Project_Keys list as a named constant at the module level, containing all recognized vendor project keys as an array of strings (initially containing "AA_VECIMA").
|
||||
2. THE Creation_Modal SHALL define the vendor issue type options as a named constant array (initially containing "Epic", "Story", "Task", "Defect", "Production Defect/Incident Fix", "New Feature", "Spike", "Release Candidate", "Documentation") at the module level adjacent to the Vendor_Project_Keys constant.
|
||||
3. THE Creation_Modal SHALL define the STEAM issue type options as a named constant array (initially containing "Story", "Epic", "Program", "Project", "Reservation", "Automation Maintenance") at the module level adjacent to the Vendor_Project_Keys constant.
|
||||
4. WHEN the user selects a project key that is included in the Vendor_Project_Keys constant, THE Creation_Modal SHALL populate the issue type dropdown exclusively with the vendor issue type options.
|
||||
5. WHEN the user selects a project key that is not included in the Vendor_Project_Keys constant or leaves the project key empty, THE Creation_Modal SHALL populate the issue type dropdown exclusively with the STEAM issue type options.
|
||||
6. WHEN a new vendor project key needs to be supported, THE developer SHALL add the key string to the Vendor_Project_Keys constant array without modifying any rendering logic or conditional branching elsewhere in the module.
|
||||
|
||||
### Requirement 4: Preserve Selected Issue Type When Project Context Does Not Change
|
||||
|
||||
**User Story:** As a security analyst, I want my selected issue type to be preserved when I make minor edits to the project key that do not change the project context (STEAM vs vendor), so that I do not lose my selection unnecessarily.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE the user edits the Project_Key_Input and the project context remains the same (both old and new values resolve to STEAM or both resolve to vendor), THE Issue_Type_Dropdown SHALL retain the currently selected issue_type value.
|
||||
2. IF the user selects an issue type and then edits the project key such that the project context changes (STEAM to vendor or vendor to STEAM), THEN THE Issue_Type_Dropdown SHALL reset the selected issue_type value to empty because the previously selected type may not exist in the new context.
|
||||
|
||||
### Requirement 5: Default Issue Type Behavior for Vendor Projects
|
||||
|
||||
**User Story:** As a security analyst, I want a sensible default indicated for vendor projects, so that I know what issue type will be used if I do not explicitly select one.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Issue_Type_Dropdown displays vendor issue types and no option has been explicitly selected by the user, THE Issue_Type_Dropdown SHALL display "Story (default)" as the visible placeholder option with an empty string as its form value, matching the existing STEAM project Issue_Type_Dropdown behavior.
|
||||
2. WHEN the user submits the Creation_Modal with the Issue_Type_Dropdown value equal to an empty string while in vendor project context, THE Creation_Modal SHALL send the issue_type field as an empty string in the request payload, causing the Ticket_Creation_Service to apply the default from the JIRA_ISSUE_TYPE environment variable.
|
||||
3. WHEN the user explicitly selects a non-default option from the vendor Issue_Type_Dropdown, THE Creation_Modal SHALL send the selected issue type name as the issue_type field value in the request payload, and the Ticket_Creation_Service SHALL use that value instead of the JIRA_ISSUE_TYPE environment variable default.
|
||||
|
||||
### Requirement 6: Vendor Issue Types in Ivanti Queue Context
|
||||
|
||||
**User Story:** As a security analyst, I want the vendor issue type dropdown to work correctly when I create a Jira ticket from the Ivanti queue, so that I can select the appropriate issue type for vendor projects without leaving my queue workflow.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user opens the Creation_Modal from the Ivanti queue "Create Jira Ticket" action and enters a project key in the Project_Key_Input that matches a value in the Vendor_Project_Keys list (case-insensitive), THE Issue_Type_Dropdown SHALL display the vendor issue type options: Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation.
|
||||
2. WHEN the user opens the Creation_Modal from the Ivanti queue and the Project_Key_Input is empty or contains a project key not in the Vendor_Project_Keys list, THE Issue_Type_Dropdown SHALL display the STEAM issue type options: Story, Epic, Program, Project, Reservation, Automation Maintenance.
|
||||
3. WHEN the user changes the Project_Key_Input value in the Ivanti queue Creation_Modal such that the project context switches (STEAM to vendor or vendor to STEAM), THE Issue_Type_Dropdown SHALL reset the selected issue_type value to empty and update the displayed options to match the new project context.
|
||||
4. WHEN the Creation_Modal is opened from the Ivanti queue with a pre-populated project_key that matches a value in the Vendor_Project_Keys list, THE Issue_Type_Dropdown SHALL display the vendor issue type options on initial render without requiring additional user input.
|
||||
5. THE Ivanti queue Creation_Modal SHALL reference the same Vendor_Project_Keys constant and the same issue type option lists as the Jira page Creation_Modal.
|
||||
|
||||
### Requirement 7: Backend Accepts Vendor Issue Types
|
||||
|
||||
**User Story:** As a security analyst, I want the backend to accept vendor issue types in the create-ticket payload, so that tickets are created with the correct type in the vendor Jira project.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a create-ticket request includes an issue_type value that is a non-empty string of 1 to 100 characters, THE Ticket_Creation_Service SHALL pass that value to the Jira API as the issuetype name field without modification.
|
||||
2. WHEN a create-ticket request does not include an issue_type field, or includes it as null or empty string, THE Ticket_Creation_Service SHALL use the value of the JIRA_ISSUE_TYPE environment variable as the issuetype name sent to the Jira API.
|
||||
3. THE Ticket_Creation_Service SHALL NOT validate the issue_type value against a fixed list of allowed types, because the valid issue types depend on the target Jira project configuration.
|
||||
4. IF the Jira API rejects the request due to an invalid issue type for the target project, THEN THE Ticket_Creation_Service SHALL return the Jira error response to the caller with an HTTP 502 status and an error message indicating the issue type was not accepted by the target project.
|
||||
102
.kiro/specs/vendor-issue-type-dropdown/tasks.md
Normal file
102
.kiro/specs/vendor-issue-type-dropdown/tasks.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Implementation Plan: Vendor Issue Type Dropdown
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements context-aware issue type selection in the Jira ticket creation modals. The core logic is a pair of pure functions (`isVendorProject`, `getIssueTypesForProject`) backed by three module-level constants. Both the JiraPage modal and the Ivanti queue modal consume the same constants and functions. The backend requires no changes.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add vendor issue type constants and determination functions
|
||||
- [x] 1.1 Create constants and helper functions in JiraPage.js
|
||||
- Add `VENDOR_PROJECT_KEYS`, `VENDOR_ISSUE_TYPES`, and `STEAM_ISSUE_TYPES` as module-level constants at the top of `frontend/src/components/pages/JiraPage.js` (adjacent to existing STYLES)
|
||||
- Add `isVendorProject(projectKey, vendorKeys)` pure function
|
||||
- Add `getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes)` pure function
|
||||
- Export the constants and functions so IvantiTodoQueuePage can import them
|
||||
- _Requirements: 1.1, 3.1, 3.2, 3.3, 3.6_
|
||||
|
||||
- [x]* 1.2 Write property test for issue type list determination
|
||||
- **Property 1: Issue type list determination**
|
||||
- Generate arbitrary strings as project keys; verify that after trim+uppercase, membership in VENDOR_PROJECT_KEYS returns VENDOR_ISSUE_TYPES, otherwise STEAM_ISSUE_TYPES
|
||||
- **Validates: Requirements 1.2, 1.3, 1.4, 2.1, 2.3, 3.4, 3.5**
|
||||
|
||||
- [x]* 1.3 Write unit tests for isVendorProject and getIssueTypesForProject
|
||||
- Test exact match: `isVendorProject('AA_VECIMA', ['AA_VECIMA'])` → true
|
||||
- Test non-match: `isVendorProject('STEAM-123', ['AA_VECIMA'])` → false
|
||||
- Test empty string: `isVendorProject('', ['AA_VECIMA'])` → false
|
||||
- Test null/undefined: `isVendorProject(null, ['AA_VECIMA'])` → false
|
||||
- Test case-insensitive: `isVendorProject('aa_vecima', ['AA_VECIMA'])` → true
|
||||
- Test whitespace trimming: `isVendorProject(' AA_VECIMA ', ['AA_VECIMA'])` → true
|
||||
- _Requirements: 1.2, 1.3, 1.4, 2.1, 2.3_
|
||||
|
||||
- [x] 2. Update JiraPage modal with dynamic issue type dropdown
|
||||
- [x] 2.1 Update project_key onChange handler to detect context switches
|
||||
- Modify the `project_key` input's onChange to call `isVendorProject()` on both old and new values
|
||||
- Reset `issue_type` to `''` only when context switches (vendor ↔ STEAM)
|
||||
- Preserve `issue_type` when context does not change
|
||||
- _Requirements: 1.5, 1.6, 4.1, 4.2_
|
||||
|
||||
- [x] 2.2 Render Issue Type dropdown dynamically from getIssueTypesForProject
|
||||
- Replace the hardcoded `<option>` elements with a dynamic render using `getIssueTypesForProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES)`
|
||||
- Keep the `<option value="">Story (default)</option>` placeholder
|
||||
- Map the returned array to `<option>` elements
|
||||
- _Requirements: 1.2, 1.3, 5.1_
|
||||
|
||||
- [x]* 2.3 Write property test for context switch resets issue type
|
||||
- **Property 2: Context switch resets issue type selection**
|
||||
- Generate pairs of project keys where one resolves to vendor and the other to STEAM; simulate the onChange logic; verify issue_type resets to empty string
|
||||
- **Validates: Requirements 1.5, 4.2, 6.3**
|
||||
|
||||
- [x]* 2.4 Write property test for same context preserves issue type
|
||||
- **Property 3: Same context preserves issue type selection**
|
||||
- Generate pairs of project keys that both resolve to the same context plus an arbitrary issue_type string; simulate the onChange logic; verify issue_type is preserved
|
||||
- **Validates: Requirements 4.1**
|
||||
|
||||
- [x] 3. Checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Update Ivanti queue modal with project_key and issue_type fields
|
||||
- [x] 4.1 Extend singleJiraForm state with project_key and issue_type fields
|
||||
- Add `project_key: ''` and `issue_type: ''` to the `singleJiraForm` initial state in `IvantiTodoQueuePage.js`
|
||||
- Import `VENDOR_PROJECT_KEYS`, `VENDOR_ISSUE_TYPES`, `STEAM_ISSUE_TYPES`, `isVendorProject`, and `getIssueTypesForProject` from JiraPage.js
|
||||
- _Requirements: 6.1, 6.2, 6.5_
|
||||
|
||||
- [x] 4.2 Add Project Key input and Issue Type dropdown to the Ivanti queue modal
|
||||
- Add a Project Key text input with `toUpperCase()` onChange and context-switch reset logic (same pattern as JiraPage)
|
||||
- Add an Issue Type `<select>` that renders dynamically from `getIssueTypesForProject`
|
||||
- Include the `<option value="">Story (default)</option>` placeholder
|
||||
- Ensure pre-populated `project_key` values trigger correct issue type list on initial render
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x]* 4.3 Write unit tests for Ivanti queue modal vendor issue type behavior
|
||||
- Test that entering a vendor key shows vendor issue types
|
||||
- Test that empty project key shows STEAM issue types
|
||||
- Test that context switch resets issue_type to empty
|
||||
- Test that pre-populated vendor key shows vendor types on mount
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 5. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The backend requires NO changes — it already passes `issue_type` through to the Jira API unchanged (Requirements 7.1–7.4 are already satisfied)
|
||||
- All constants and functions are co-located in JiraPage.js and exported for reuse by IvantiTodoQueuePage.js
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["1.2", "1.3", "2.1", "2.2"] },
|
||||
{ "id": 2, "tasks": ["2.3", "2.4", "4.1"] },
|
||||
{ "id": 3, "tasks": ["4.2"] },
|
||||
{ "id": 4, "tasks": ["4.3"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
83
.kiro/steering/archer-template-gen.md
Normal file
83
.kiro/steering/archer-template-gen.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# Archer Template Content Generator
|
||||
|
||||
When this steering file is active, the user will provide a brief, informal description of a network device and its environment. Your job is to expand that into three formal Archer Risk Acceptance sections suitable for direct paste into the Archer application or into the STEAM Dashboard's Archer Template Library.
|
||||
|
||||
## IMPORTANT: Web Search Step
|
||||
|
||||
Before generating content, you MUST use the web search tool to look up the specific vendor and model mentioned by the user. Search for:
|
||||
1. "[Vendor] [Model] datasheet" — to get hardware specs, supported protocols, OS details, and form factor
|
||||
2. "[Vendor] [Model] network security" or "[Vendor] [Model] hardening guide" — to find security-relevant capabilities
|
||||
|
||||
Use the search results to enrich the generated content with accurate, model-specific details such as:
|
||||
- Exact OS name and base (e.g., "Junos OS Evolved" vs "Junos OS", "IOS-XR 7.x", "AOS-W")
|
||||
- Supported interface types and throughput capacity
|
||||
- Built-in security features (MACsec, hardware encryption, TPM, secure boot)
|
||||
- Typical deployment role per vendor documentation
|
||||
- Protocol support relevant to the environment (segment routing, EVPN, VXLAN, etc.)
|
||||
|
||||
If the search returns useful specs, weave them naturally into the Environment Overview. Do NOT just dump raw specs — integrate them into the Archer prose format.
|
||||
|
||||
## Output Sections
|
||||
|
||||
Generate exactly three sections:
|
||||
|
||||
1. **Environment Overview**
|
||||
2. **Segmentation**
|
||||
3. **Mitigating Controls**
|
||||
|
||||
## Rules
|
||||
|
||||
- Infer reasonable technical details from the device type, vendor, and role. If you know the platform OS (Junos for Juniper, IOS-XR for Cisco, EOS for Arista, embedded Linux for RPDs, ADTRAN-specific OS for OLTs), include it.
|
||||
- Fill in standard security posture details based on the environment described (CTEC lab vs production field, internet-facing vs isolated behind VPN, etc.).
|
||||
- Write as continuous prose with paragraph breaks between logical subsections. No bullets, no markdown headers, no formatting — this gets pasted directly into Archer text fields.
|
||||
- If something is unknown from the input, make a reasonable assumption for the device class and state it naturally.
|
||||
- Always include CrowdStrike/SentinelOne status — assume NOT installed on network infrastructure unless told otherwise.
|
||||
- Always end Mitigating Controls with the authentication method (TACACS, ISE, RADIUS, local, etc.).
|
||||
|
||||
## Section Structure
|
||||
|
||||
### Environment Overview must include:
|
||||
- What the device is and its function
|
||||
- Operating system
|
||||
- Typical deployment location
|
||||
- Data categories: processed/transmitted (in transit) and stored (on device)
|
||||
- General data processing flow: ingress, processing, egress, disposition
|
||||
- Application/environment description with service/functionality
|
||||
- Geographic deployment
|
||||
- Applications in use
|
||||
|
||||
### Segmentation must include:
|
||||
- Network segmentation controls (VLANs, VRFs, routing policies, prefix lists, ASN isolation)
|
||||
- Perimeter firewalls/PE ACLs with explicit deny/allow patterns
|
||||
- Private addressing (RFC1918, no NAT to internet)
|
||||
- Transport protection (MPLS, optical, IPsec, MACsec as applicable)
|
||||
- Service hardening (disabled unused services, SSH v2, SNMPv3, TLS)
|
||||
- Access controls restricting communications
|
||||
|
||||
### Mitigating Controls must include:
|
||||
- Internet accessibility posture (not accessible from public internet)
|
||||
- External-facing services (none, or list what's internal-only)
|
||||
- Controls restricting external access (ACL rules with specific allow/deny)
|
||||
- Administrative access isolation (jump host/bastion, MFA, PAM, break-glass, OOB management)
|
||||
- Endpoint security agent status (CrowdStrike Falcon, SentinelOne — typically not installed on network infra)
|
||||
- Authentication method (TACACS+/Cisco ISE, RADIUS, local, etc.)
|
||||
|
||||
## Reference Context
|
||||
|
||||
These are from approved Charter Red Network Archer exceptions for similar device classes:
|
||||
|
||||
- RPDs (Harmonic, Vecima): R-PHY devices in the field, embedded Linux, DOCSIS traffic, not internet-accessible, MPLS transport, TACACS/ISE auth
|
||||
- OLTs (Adtran): PON service devices in CTEC, ADTRAN-specific OS, Blue VPN access only, null-routed external paths, TACACS auth
|
||||
- vCMTS (Harmonic CableOS): Virtual CMTS cores, Linux-based, headend/datacenter, DOCSIS downstream/upstream, private MPLS transport
|
||||
|
||||
## How to Use
|
||||
|
||||
The user will type something informal like:
|
||||
|
||||
> Juniper PTX10003, Hub Core Router in CTEC. Backbone of the lab. Access behind Blue VPN. Routing policies and prefix lists limited to CTEC ASN. Junos.
|
||||
|
||||
You respond with the three generated sections, each clearly labeled, ready to copy into the template library.
|
||||
111
.kiro/steering/firewall-request-template.md
Normal file
111
.kiro/steering/firewall-request-template.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# Firewall Exception Request Template
|
||||
|
||||
When the user needs to generate a Red/Blue Firewall Exception Request (LNE > Lab Network Request ticket), use this template structure. Adapt the content based on the specific service requiring network access.
|
||||
|
||||
## Template Structure
|
||||
|
||||
Every firewall request document must include these sections in order:
|
||||
|
||||
### 1. Title and Purpose
|
||||
|
||||
```
|
||||
# <Service Name> Firewall Exception Request — <Application Name>
|
||||
|
||||
Reference material for the LNE > Lab Network Request ticket required by
|
||||
<reason for the block / policy reference>. All targets are <source of truth
|
||||
for the destination list>.
|
||||
```
|
||||
|
||||
### 2. Destinations Table
|
||||
|
||||
| Cluster/Service | Destination IP/Host | Port(s) | Protocol | Encryption |
|
||||
|---|---|---|---|---|
|
||||
| *Name of the service* | *IP or hostname* | *port(s)* | TCP/UDP | *TLS, PLAINTEXT, etc.* |
|
||||
|
||||
Include notes about connection type: stateful TCP, client-initiated outbound, ephemeral source port, etc.
|
||||
|
||||
### 3. Source
|
||||
|
||||
- Source host: `<hostname>`
|
||||
- Source IP: `<IP address>`
|
||||
- Source port: `ephemeral / 1024-65535`
|
||||
|
||||
### 4. Requested Firewall Rules
|
||||
|
||||
Provide both per-IP rules and summary rules (if LNE accepts them):
|
||||
|
||||
```
|
||||
Rule N
|
||||
Source: <source IP>/32 ephemeral
|
||||
Destination: <dest IP>/32 tcp/<port>
|
||||
Protocol: TCP
|
||||
Action: allow
|
||||
```
|
||||
|
||||
### 5. Suggested Ticket Text
|
||||
|
||||
Follow the LNE template fields exactly:
|
||||
|
||||
> **Summary:** Red/Blue FW Request: <Source App> → <Destination Service> (<brief description>)
|
||||
>
|
||||
> 1. **Reason:** What the application does and why it needs this access.
|
||||
> 2. **Drivers:** Operational, compliance, or business justification. State explicitly: no PCI, no CPNI if applicable.
|
||||
> 3. **Application purpose:** What the app does with the data it gets from the destination. Mention rate limits, compliance with API policies, etc.
|
||||
> 4. **Source/destination IPs and ports:** Summary of flows (count, protocol, direction).
|
||||
> 5. **Impact if denied:** What breaks, what the fallback is, and why the fallback is worse.
|
||||
>
|
||||
> **Environment Overview**
|
||||
> 1. Data: What data flows over this connection. Explicitly state no PII/PCI/CPNI if true.
|
||||
> 2. OS: Operating system on the source host.
|
||||
> 3. OS compliance / risk acceptance: Fill in if applicable.
|
||||
> 4. Architecture: Brief description of the application architecture.
|
||||
>
|
||||
> **External access:** State whether the source host is internet-exposed or internal-only.
|
||||
>
|
||||
> **Mitigation controls:** How access to the source host is controlled, how credentials are stored, what security measures are in place.
|
||||
|
||||
### 6. Evidence to Attach
|
||||
|
||||
Describe what test script to run and what log files to attach. The log should provide timestamped proof of the connection behavior (success or failure) from the source host.
|
||||
|
||||
### 7. Notes
|
||||
|
||||
Any additional context: auth methods, TLS settings, environment differences between UAT and production, etc.
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Be specific about IPs, ports, and protocols. Avoid vague descriptions.
|
||||
- Explicitly state what data does NOT flow (no PII, no PCI, no CPNI) to speed risk assessment.
|
||||
- Reference test evidence that proves the current behavior without speculation.
|
||||
- If a test script exists, reference it and describe its output format.
|
||||
- Use `/32` for single-host rules. Only use CIDR aggregates if you explain what extra IPs are included.
|
||||
- State whether TLS is used. If PLAINTEXT, explain why (e.g., internal Kafka brokers with no client auth).
|
||||
- Mention rate limiting and compliance with any API usage policies if applicable.
|
||||
|
||||
---
|
||||
|
||||
## Existing Firewall Request Documents
|
||||
|
||||
Reference these for examples of completed requests:
|
||||
|
||||
- `docs/kafka-firewall-request.md` — ZBL Impairment Map → Charter Kafka telemetry feeds (STAMP + DAA RPHY Prod). TCP PLAINTEXT to specific broker IPs on non-standard ports.
|
||||
- The Jira request (generated in chat) — STEAM Dashboard → jira.charter.com:443. Single HTTPS flow with Basic Auth service account.
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Submitting
|
||||
|
||||
- [ ] All destination IPs/hostnames and ports are listed
|
||||
- [ ] Source host IP is filled in (not placeholder)
|
||||
- [ ] Protocol and encryption are specified for each flow
|
||||
- [ ] Ticket text follows the LNE template fields
|
||||
- [ ] Data classification is stated (PII/PCI/CPNI or lack thereof)
|
||||
- [ ] Test evidence is attached or referenced
|
||||
- [ ] Impact-if-denied section explains the operational consequence
|
||||
- [ ] Mitigation controls describe how the source host is secured
|
||||
48
.kiro/steering/gitlab-workflow.md
Normal file
48
.kiro/steering/gitlab-workflow.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# GitLab Issue Traceability
|
||||
|
||||
## Commit Messages
|
||||
|
||||
When a commit fixes or resolves a GitLab issue:
|
||||
|
||||
- Include `Closes #N` (or `Fixes #N`) on its own line at the end of the commit message body, where N is the GitLab issue number.
|
||||
- If the fix addresses multiple issues, include one `Closes #N` per issue, each on its own line.
|
||||
- The CI pipeline's `after_script` will auto-comment on the referenced issues with a link to the deploy pipeline.
|
||||
|
||||
Example:
|
||||
```
|
||||
Fix duplicate chart entries on compliance page
|
||||
|
||||
Aggregate /trends and /category-trend by report_date instead of per-upload row.
|
||||
|
||||
Closes #12
|
||||
```
|
||||
|
||||
## Issue References in Code
|
||||
|
||||
When working from a GitLab issue, reference the issue number in:
|
||||
- The commit message (required — `Closes #N`)
|
||||
- The spec's `bugfix.md` introduction (for traceability from spec to issue)
|
||||
- Test file header comments (optional — helps future developers find context)
|
||||
|
||||
## Closing Issues
|
||||
|
||||
- Prefer closing issues via commit message keywords (`Closes`, `Fixes`, `Resolves`) so GitLab auto-closes them when the pipeline succeeds on the default branch.
|
||||
- If a commit was pushed without the keyword, close the issue via the API after confirming the deploy succeeded.
|
||||
- **Always post a brief summary comment** on the issue when closing it. One or two sentences describing what was done is enough — the goal is that anyone reading the issue later understands the resolution without digging through commits. Post the comment via the GitLab API:
|
||||
```bash
|
||||
curl --silent --request POST \
|
||||
--header "PRIVATE-TOKEN: $GITLAB_PAT" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"body": "Fixed in <commit>. <1-2 sentence summary of what changed>."}' \
|
||||
"http://steam-gitlab.charterlab.com/api/v4/projects/13/issues/<N>/notes"
|
||||
```
|
||||
|
||||
## Pipeline Notifications
|
||||
|
||||
The `deploy-staging` and `deploy-production` jobs in `.gitlab-ci.yml` have `after_script` blocks that parse `#N` references from the commit message and post a comment on each referenced issue with a link to the pipeline. This requires `GITLAB_PAT` to be set as a CI/CD variable in the project settings.
|
||||
|
||||
### Setup Required
|
||||
|
||||
1. Go to **Settings → CI/CD → Variables** in the GitLab project
|
||||
2. Add variable: `GITLAB_PAT` = the project access token (already exists as `glpat-...` in `backend/.env`)
|
||||
3. Mark it as **Protected** and **Masked**
|
||||
@@ -5,31 +5,48 @@
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Node.js 18+, Express 5 |
|
||||
| Database | SQLite3 (file: `backend/cve_database.db`) |
|
||||
| Database | PostgreSQL (via `pg` pool in `backend/db.js`) |
|
||||
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
||||
| File uploads | Multer 2 (10MB limit) |
|
||||
| Frontend | React 19 (Create React App / react-scripts 5) |
|
||||
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
||||
| UI Icons | lucide-react |
|
||||
| Charts | recharts |
|
||||
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
||||
| Markdown rendering | react-markdown |
|
||||
| Diagrams | mermaid |
|
||||
|
||||
## Architecture: Single-Port Serving
|
||||
|
||||
Express on port 3001 serves **both** the API and the production frontend build:
|
||||
- API routes: `/api/*` — handled by Express route handlers
|
||||
- Frontend: everything else — served as static files from `frontend/build/`
|
||||
|
||||
There is no separate frontend server in production. The React dev server (`npm start` on port 3000) is only for local development with hot-reload. In production and on the dev server, you must run `npm run build` in `frontend/` after any frontend code change, then restart the backend.
|
||||
|
||||
**After editing frontend source files:**
|
||||
```bash
|
||||
cd frontend && npm run build # Compile new bundle into frontend/build/
|
||||
# Then restart backend (or it will serve the new static files on next request)
|
||||
```
|
||||
|
||||
The CI/CD pipeline handles this automatically — `build-frontend` stage runs before deploy.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
node setup.js # Initialize DB, tables, indexes, default admin user
|
||||
node server.js # Start backend on port 3001
|
||||
node server.js # Start backend on port 3001 (serves API + frontend build)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
npm start # Dev server on port 3000
|
||||
npm run build # Production build
|
||||
npm run build # Production build → frontend/build/ (REQUIRED after code changes)
|
||||
npm start # Dev server on port 3000 (local dev only, NOT used in production)
|
||||
npm test # Run tests (react-scripts test)
|
||||
```
|
||||
|
||||
@@ -39,16 +56,9 @@ npm test # Run tests (react-scripts test)
|
||||
./stop-servers.sh # Stop all servers
|
||||
```
|
||||
|
||||
### Database Migrations (run from `backend/` in order)
|
||||
### Database Migrations (run from `backend/`)
|
||||
```bash
|
||||
node migrations/add_knowledge_base_table.js
|
||||
node migrations/add_archer_tickets_table.js
|
||||
node migrations/add_ivanti_sync_table.js
|
||||
node migrations/add_ivanti_findings_tables.js
|
||||
node migrations/add_ivanti_todo_queue_table.js
|
||||
node migrations/add_card_workflow_type.js
|
||||
node migrations/add_todo_queue_ip_address.js
|
||||
node migrations/add_compliance_tables.js
|
||||
node migrations/run-all.js # Runs all migrations in order (idempotent)
|
||||
```
|
||||
|
||||
### Python Scripts (from `backend/scripts/`)
|
||||
@@ -68,11 +78,64 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
|
||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
||||
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
||||
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
||||
- React caches env vars at build/start time — restart the frontend process after changes.
|
||||
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
||||
|
||||
## Default Ports
|
||||
## Code Style & Lint Rules
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Frontend | http://localhost:3000 |
|
||||
| Backend API | http://localhost:3001 |
|
||||
### Unused Variables
|
||||
|
||||
The frontend ESLint config enforces `no-unused-vars` as a warning. The CI pipeline fails if warnings exceed 25. To avoid lint failures:
|
||||
|
||||
- **Prefix intentionally-unused variables with `_`** — this suppresses the warning. The `varsIgnorePattern: "^_"` and `argsIgnorePattern: "^_"` rules are configured in `frontend/package.json`.
|
||||
- Common patterns:
|
||||
- `const [_unused, setFoo] = useState(...)` — destructured value you don't need
|
||||
- `const _legacyRef = useRef(...)` — kept for future use
|
||||
- `function handler(_event) { ... }` — required parameter signature but unused
|
||||
- **Do not leave variables unprefixed if unused.** Either use them, remove them, or prefix with `_`.
|
||||
- This applies to all frontend code written by the agent.
|
||||
|
||||
### Backend
|
||||
|
||||
No ESLint is configured for backend — the pipeline uses `node -c` syntax checking only. Keep code clean but there is no automated unused-var enforcement on the backend side.
|
||||
|
||||
## Ports
|
||||
|
||||
| Environment | URL | Notes |
|
||||
|---|---|---|
|
||||
| Production | http://71.85.90.6:3001 | Express serves API + static frontend build |
|
||||
| Staging | http://71.85.90.9:3100 | Auto-deploy on master push |
|
||||
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Role | Host | Notes |
|
||||
|---|---|---|
|
||||
| GitLab instance | steam-gitlab.charterlab.com | Self-hosted GitLab |
|
||||
| CI Runner (LXC 108) | 71.85.90.8 | Docker executor, Runner #6, project-locked |
|
||||
| Staging target | 71.85.90.9 | Auto-deploy on master, port 3100 |
|
||||
| Production target | 71.85.90.6 | Manual deploy trigger, port 3001 |
|
||||
|
||||
### Executor: Docker
|
||||
|
||||
The pipeline uses **Docker executor** on Runner #6. Jobs run in isolated containers:
|
||||
|
||||
- **Install / Lint / Test / Build stages**: `node:18` image
|
||||
- **Deploy stages**: `alpine:latest` image (installs `openssh-client` and `rsync` at runtime)
|
||||
|
||||
Deploy jobs SSH from inside the Alpine container to the target hosts using a base64-encoded `$SSH_PRIVATE_KEY` stored as a GitLab CI/CD variable.
|
||||
|
||||
### CI/CD Variables (project-level)
|
||||
|
||||
These are set in GitLab → Settings → CI/CD → Variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string for backend integration tests |
|
||||
| `SSH_PRIVATE_KEY` | Base64-encoded private key for deploy SSH access |
|
||||
| `GITLAB_PAT` | Project access token for issue comments and release creation |
|
||||
|
||||
### Pipeline file
|
||||
|
||||
The pipeline is defined in `.gitlab-ci.yml` at the project root. Stages: install → lint → test → build → deploy → verify.
|
||||
|
||||
83
.kiro/steering/versioning.md
Normal file
83
.kiro/steering/versioning.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Versioning & Release Management
|
||||
|
||||
## Version Numbering
|
||||
|
||||
This project uses **Semantic Versioning** (MAJOR.MINOR.PATCH) but with a practical cadence-based approach to avoid runaway patch numbers.
|
||||
|
||||
### When to bump what
|
||||
|
||||
| Change type | Bump | Example |
|
||||
|---|---|---|
|
||||
| Breaking change (new DB engine, incompatible API/config, data migration required) | MAJOR | 2.0.0 → 3.0.0 |
|
||||
| New feature, new page, new integration, significant enhancement | MINOR | 2.1.0 → 2.2.0 |
|
||||
| Bug fix, UI tweak, docs update, refactor with no user-visible change | PATCH | 2.1.0 → 2.1.1 |
|
||||
|
||||
### Cadence rules to keep numbers sane
|
||||
|
||||
- **Bundle bug fixes into the next minor release** rather than tagging a patch for every individual fix. Only cut a standalone patch release (x.y.Z) if a fix is urgent and needs to ship before the next feature is ready.
|
||||
- **One minor bump per feature batch** — if a work session produces 2–3 features and 5 bug fixes, that's one minor release, not five patches and two minors.
|
||||
- **Tag releases at logical milestones**, not per-commit. A good release boundary is: "a user would notice something new or different."
|
||||
- **Never exceed x.y.5 in patches** before rolling into the next minor. If you're at x.y.5 and still shipping fixes, just bump to x.(y+1).0 and include the fixes there.
|
||||
|
||||
### Practical workflow
|
||||
|
||||
1. Work on features and fixes on `master` as normal — no version bump per commit.
|
||||
2. When a logical batch is complete (end of a sprint, feature area done, before a deploy you want to mark), decide the version:
|
||||
- Any breaking change since last tag? → MAJOR
|
||||
- Any new features? → MINOR
|
||||
- Only fixes? → PATCH (but prefer bundling into next MINOR)
|
||||
3. Update `CHANGELOG.md` with the new version section.
|
||||
4. Commit the changelog update.
|
||||
5. Tag and push:
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z — short summary"
|
||||
git push origin vX.Y.Z
|
||||
git push backup vX.Y.Z
|
||||
```
|
||||
6. Create a GitLab Release from the tag (renders changelog on the Releases page):
|
||||
```bash
|
||||
# Extract the changelog section for this version from CHANGELOG.md
|
||||
# Then create the release via GitLab API:
|
||||
curl --silent --request POST \
|
||||
--header "PRIVATE-TOKEN: $GITLAB_PAT" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{
|
||||
\"tag_name\": \"vX.Y.Z\",
|
||||
\"name\": \"vX.Y.Z\",
|
||||
\"description\": \"<changelog section in markdown>\"
|
||||
}" \
|
||||
"http://steam-gitlab.charterlab.com/api/v4/projects/steam%2Fcve-dashboard/releases"
|
||||
```
|
||||
|
||||
### GitLab Release creation details
|
||||
|
||||
- The GitLab instance is `http://steam-gitlab.charterlab.com`
|
||||
- Project path: `steam/cve-dashboard` (URL-encoded: `steam%2Fcve-dashboard`)
|
||||
- Auth: `GITLAB_PAT` from `backend/.env`
|
||||
- The `description` field accepts full markdown — paste the relevant `## [vX.Y.Z]` section from `CHANGELOG.md`
|
||||
- The release appears under **Deployments → Releases** in the GitLab sidebar with rendered markdown, download archives, and a badge showing the latest version
|
||||
|
||||
### What counts as a "breaking change"
|
||||
|
||||
- Database engine or schema change that requires migration with data transformation
|
||||
- Removal or rename of API endpoints that external consumers depend on
|
||||
- Environment variable changes that would break an existing deployment on pull
|
||||
- Dropping support for a previously supported platform or runtime version
|
||||
|
||||
Adding new required env vars for *new* features is NOT breaking — existing features still work without them.
|
||||
|
||||
## Release Suggestion Prompt
|
||||
|
||||
After completing work (features, fixes, or both), suggest the next version number based on:
|
||||
|
||||
1. What the last tagged version is (`git tag -l --sort=-v:refname | head -1`)
|
||||
2. What changed since that tag (`git log <last_tag>..HEAD --oneline`)
|
||||
3. The cadence rules above
|
||||
|
||||
Format the suggestion as:
|
||||
|
||||
> **Suggested release:** vX.Y.Z
|
||||
> **Reason:** [brief justification based on change types]
|
||||
> **Changelog entries to add:** [bullet list of items to add]
|
||||
|
||||
Only suggest a release if there are meaningful user-visible changes. Internal refactors, test additions, and CI tweaks alone do not warrant a release.
|
||||
Reference in New Issue
Block a user