Files
cve-dashboard/.kiro/specs/fp-attachment-library/design.md

363 lines
16 KiB
Markdown
Raw Normal View History

# Design Document: FP Attachment Library
## Overview
This feature extends the FP submission workflow to let users attach documents from the existing CVE document library (the `documents` table) alongside traditional local file uploads. The core change is a new **Attachment Source Picker** component shared by both the create and edit modals, backed by a new **Document Search API** endpoint. On submission, the backend reads library files from disk and sends them to the Ivanti API identically to local uploads.
The design prioritizes minimal disruption to the existing codebase: one new GET endpoint, modifications to two existing POST endpoints, and a shared React component inserted into both modals.
```mermaid
flowchart LR
subgraph Frontend
A[FpWorkflowModal] --> C[AttachmentSourcePicker]
B[FpEditModal] --> C
C -->|local files| D[File objects in state]
C -->|library docs| E[Document IDs in state]
end
subgraph Backend
F[GET /api/documents/search] -->|SQLite| G[(documents table)]
H[POST /api/ivanti/fp-workflow] -->|reads disk| I[uploads/]
H -->|multipart| J[Ivanti API]
K[POST .../attachments] -->|reads disk| I
K -->|multipart| J
end
C -->|fetch| F
A -->|FormData + libraryDocIds| H
B -->|FormData + libraryDocIds| K
```
## Architecture
### Request Flow
1. User opens FP Create or Edit modal
2. Attachment Source Picker renders with two mode tabs: **Local Upload** and **Library**
3. In Library mode, user types a search query → frontend debounces 300ms → calls `GET /api/documents/search?q=...`
4. User selects library documents and/or local files
5. On submit:
- Frontend sends `FormData` with local files in `attachments` field and library document IDs in a `libraryDocIds` JSON field
- Backend parses both, looks up library documents in the `documents` table, reads their files from disk
- Backend combines local file buffers and library file buffers into a single `files` array
- Backend calls `ivantiFormPost` with all files in one multipart request
- Backend records results in `attachment_results_json` with a `source` field (`"local"` or `"library"`)
### Key Design Decisions
1. **Single shared component**: The `AttachmentSourcePicker` is used in both modals to avoid duplication. It receives callbacks for state management and renders the mode toggle, search UI, and unified attachment list.
2. **Library doc IDs sent as JSON field**: Rather than changing the multipart structure, library document IDs are sent as a JSON-encoded string field (`libraryDocIds`) alongside the existing `attachments` file field. This keeps the existing local upload path unchanged.
3. **Backend reads files from disk**: Library documents are read from disk using `fs.readFileSync(file_path)` at submission time. This avoids storing duplicate file buffers and keeps the Ivanti API call identical for both sources.
4. **No new database tables**: The feature uses the existing `documents` table for search and the existing `ivanti_fp_submissions` table for recording results. The only schema-level change is adding a `source` field to the JSON objects in `attachment_results_json`.
## Components and Interfaces
### New API Endpoint
#### `GET /api/documents/search`
Added to `backend/routes/ivantiFpWorkflow.js` (or as a new route in `server.js` alongside existing document routes).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | query string | No | Search term matched against `name`, `cve_id`, `vendor` using SQL `LIKE` |
**Response** (200):
```json
[
{
"id": 42,
"cve_id": "CVE-2024-1234",
"vendor": "Microsoft",
"name": "advisory-2024-1234.pdf",
"type": "Advisory",
"file_size": "245760",
"mime_type": "application/pdf",
"uploaded_at": "2024-11-15T10:30:00.000Z"
}
]
```
**Implementation**:
```javascript
// Pseudocode
router.get('/documents/search', requireAuth(db), (req, res) => {
const q = (req.query.q || '').trim();
let sql, params;
if (q) {
const like = `%${q}%`;
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
FROM documents
WHERE name LIKE ? OR cve_id LIKE ? OR vendor LIKE ?
ORDER BY uploaded_at DESC
LIMIT 50`;
params = [like, like, like];
} else {
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
FROM documents
ORDER BY uploaded_at DESC
LIMIT 50`;
params = [];
}
db.all(sql, params, (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error.' });
res.json(rows || []);
});
});
```
### Modified API Endpoints
#### `POST /api/ivanti/fp-workflow` (create)
**New field in multipart body**:
- `libraryDocIds` — JSON-encoded array of document IDs (integers) from the `documents` table
**Backend changes**:
1. Parse `libraryDocIds` from `req.body` (default to `[]`)
2. Validate each ID is a positive integer
3. Query `documents` table for matching records
4. Validate all IDs were found (400 if any missing)
5. Read each file from disk using `file_path` (error if file missing on disk)
6. Combine local file buffers (`req.files`) and library file buffers into a single `formFiles` array
7. Pass combined array to `ivantiFormPost`
8. Record results with `source: "local"` or `source: "library"` in `attachment_results_json`
#### `POST /api/ivanti/fp-workflow/submissions/:id/attachments` (edit)
Same changes as the create endpoint — accepts `libraryDocIds` alongside `attachments` files.
### New Frontend Component
#### `AttachmentSourcePicker`
Defined inline in `ReportingPage.js` (consistent with existing component patterns).
**Props**:
| Prop | Type | Description |
|------|------|-------------|
| `files` | `File[]` | Current local file attachments |
| `onFilesChange` | `(files: File[]) => void` | Callback when local files change |
| `libraryDocs` | `object[]` | Current selected library documents |
| `onLibraryDocsChange` | `(docs: object[]) => void` | Callback when library selections change |
| `disabled` | `boolean` | Disables all interactions (for approved submissions) |
**Internal state**:
- `mode``'local'` or `'library'` (default: `'local'`)
- `searchQuery` — current search input value
- `searchResults` — array of document records from API
- `searching` — loading state for search
**Behavior**:
- Mode toggle renders two tab-style buttons at the top
- Local mode shows the existing drag-and-drop zone
- Library mode shows a search input + scrollable results list
- Search is debounced at 300ms using `setTimeout`/`clearTimeout`
- Selected library docs are tracked by `id` to prevent duplicates
- Already-selected docs appear disabled/checked in search results
- Unified attachment list below shows all attachments with source badges
- Each attachment row shows: source badge, filename, file size, remove button
- Library attachment rows additionally show CVE ID and vendor
```mermaid
stateDiagram-v2
[*] --> LocalMode
LocalMode --> LibraryMode: Click "Library" tab
LibraryMode --> LocalMode: Click "Local Upload" tab
state LibraryMode {
[*] --> Idle
Idle --> Searching: User types (after 300ms debounce)
Searching --> ResultsShown: API responds
ResultsShown --> Searching: User types again
ResultsShown --> DocSelected: User clicks result
DocSelected --> ResultsShown: Doc added to list
}
```
### Integration Points
#### FpWorkflowModal (create)
- Replace the current file upload section with `<AttachmentSourcePicker>`
- Add `libraryDocs` state array alongside existing `files` state
- On submit, append `libraryDocIds` as JSON string to `FormData`:
```javascript
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
```
#### FpEditModal (edit — attachments tab)
- Replace the static "upload in Ivanti" message with `<AttachmentSourcePicker>`
- Keep existing attachment display above the picker
- On submit, build `FormData` with both local files and `libraryDocIds`
- Disable picker when `lifecycle_status === 'approved'`
## Data Models
### Existing: `documents` table (no changes)
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER PK | Auto-increment ID |
| `cve_id` | VARCHAR(20) | Associated CVE identifier |
| `vendor` | VARCHAR(100) | Vendor name |
| `name` | VARCHAR(255) | Original filename |
| `type` | VARCHAR(50) | Document type (Advisory, Patch, etc.) |
| `file_path` | VARCHAR(500) | Relative path under `uploads/` |
| `file_size` | VARCHAR(20) | Human-readable or byte size |
| `mime_type` | VARCHAR(100) | MIME type |
| `uploaded_at` | TIMESTAMP | Upload timestamp |
| `notes` | TEXT | Optional notes |
### Modified: `attachment_results_json` shape
Current format per entry:
```json
{ "filename": "report.pdf", "success": true }
```
New format per entry:
```json
{ "filename": "report.pdf", "success": true, "source": "local" }
```
or:
```json
{ "filename": "advisory-2024.pdf", "success": true, "source": "library", "documentId": 42 }
```
The `source` field is added to distinguish attachment origins. The `documentId` field is included for library documents to enable traceability. Existing records without a `source` field are treated as `"local"` by the frontend for backward compatibility.
### Frontend State: Library Document Selection
```javascript
// Shape of a selected library document in component state
{
id: 42, // documents.id
cve_id: "CVE-2024-1234",
vendor: "Microsoft",
name: "advisory-2024-1234.pdf",
file_size: "245760",
mime_type: "application/pdf"
}
```
## 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: Search results match query
*For any* non-empty search query string `q` and any set of documents in the database, every document returned by the Document Search API SHALL have `q` as a case-insensitive substring of its `name`, `cve_id`, or `vendor` field.
**Validates: Requirements 1.1**
### Property 2: Default results are ordered by recency
*For any* set of documents in the database, when the Document Search API is called with no query, the returned results SHALL be ordered by `uploaded_at` descending (most recent first).
**Validates: Requirements 1.2**
### Property 3: Result set size is bounded
*For any* search query (including empty), the Document Search API SHALL return at most 50 records.
**Validates: Requirements 1.3**
### Property 4: Library document ID validation rejects non-positive-integers
*For any* value that is not a positive integer (e.g., negative numbers, zero, floats, non-numeric strings, null), the backend validation SHALL reject it as an invalid library document ID.
**Validates: Requirements 4.5**
### Property 5: Combined attachments are all sent to Ivanti
*For any* combination of local file uploads and library document references in a submission, the backend SHALL produce a files array for the Ivanti API call whose length equals the count of local files plus the count of valid library documents, and each library file buffer SHALL match the content read from the document's `file_path`.
**Validates: Requirements 4.1, 4.2**
### Property 6: Attachment results record source and filename correctly
*For any* mix of local and library attachments processed by the backend, each entry in `attachment_results_json` SHALL have a `source` field of `"local"` or `"library"`, and for library entries the `filename` SHALL equal the `name` field from the corresponding `documents` record.
**Validates: Requirements 4.6, 4.7**
### Property 7: No duplicate library documents in attachment list
*For any* sequence of library document selections applied to the Attachment Source Picker, the resulting attachment list SHALL contain at most one entry per document `id`.
**Validates: Requirements 5.1**
### Property 8: Attachment list displays all required fields per type
*For any* attachment in the list (local or library), the rendered display SHALL include the filename, file size, source indicator, and a remove action. *For any* library attachment, the display SHALL additionally include the CVE ID and vendor name.
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
## Error Handling
| Scenario | Behavior |
|----------|----------|
| Document search DB error | Return 500 with `{ error: 'Database error.' }` |
| Invalid `libraryDocIds` JSON | Return 400 with `{ error: 'libraryDocIds must be a valid JSON array.' }` |
| Non-positive-integer document ID | Return 400 identifying the invalid ID |
| Document ID not found in DB | Return 400 identifying the missing document ID |
| Library file missing from disk | Log warning, skip that attachment, include `{ success: false, error: 'File not found on disk' }` in attachment results, continue with remaining files |
| Ivanti API failure for attachment upload | Record `{ success: false, error: '...' }` per file in results, return partial success if some files succeeded |
| Network error calling Document Search API (frontend) | Show inline error message in search results area, allow retry |
| Empty search results | Show "No documents found" message with suggestion to refine search |
| Unauthenticated request to search endpoint | Return 401 (handled by existing `requireAuth` middleware) |
### Backward Compatibility
- Existing `attachment_results_json` entries without a `source` field are treated as `"local"` by the frontend
- The `libraryDocIds` field is optional in both create and edit endpoints — omitting it preserves current behavior exactly
- No database migrations required — the `documents` table already exists
## Testing Strategy
### Property-Based Tests (fast-check)
The project uses plain JavaScript with React 19. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) with the existing `react-scripts test` runner (Jest).
Each property test runs a minimum of 100 iterations and is tagged with a comment referencing its design property.
**Configuration**: `npm install --save-dev fast-check` in the frontend package (or backend if testing backend logic separately).
**Properties to test**:
- Property 1: Search relevance — generate random documents and queries, verify all results match
- Property 2: Default ordering — generate random documents, verify descending order
- Property 3: Result limit — generate >50 documents, verify max 50 returned
- Property 4: ID validation — generate random non-positive-integer values, verify rejection
- Property 5: Combined attachment handling — generate random mixes, verify file array correctness
- Property 6: Result record shape — generate random mixes, verify source and filename fields
- Property 7: Duplicate prevention — generate random selection sequences, verify uniqueness
- Property 8: Display completeness — generate random attachment lists, verify rendered fields
**Tag format**: `// Feature: fp-attachment-library, Property N: <property text>`
### Unit Tests (example-based)
- Authentication guard on search endpoint (1.5)
- DB error handling returns 500 (1.6)
- Mode toggle renders correctly in both modals (2.1, 2.2, 2.3)
- Debounce behavior with fake timers (2.4)
- Library doc selection adds to list with indicator (2.5)
- Remove works for both types (2.6)
- Mixed attachments in same submission (2.7)
- Library doc displays name, size, CVE ID (2.8)
- Edit modal replaces static message (3.1)
- Existing attachments shown above picker (3.4)
- Approved submission disables picker (3.5)
- Missing file on disk returns error (4.3)
- Invalid document ID returns 400 (4.4)
- Already-selected docs shown as disabled (5.2)
- Removed doc re-enabled in results (5.3)
### Integration Tests
- End-to-end create flow with mixed local + library attachments
- End-to-end edit flow adding library attachments to existing submission
- Search endpoint with real SQLite database