From 79f98414c42b1174a40b16850c1f49b076a8e410 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Mon, 8 Jun 2026 14:07:59 -0600 Subject: [PATCH] Add Remediate workflow type to Ivanti Queue with remediation notes - Add 'Remediate' as a valid workflow type (vendor-required, like FP/Archer) - Create queue_remediation_notes table with FK cascade and 5000 char limit - Add POST/GET /api/ivanti/todo-queue/:id/notes endpoints - Include remediation_notes_count in queue item GET response - Add RemediationModal component for viewing/adding notes - Add notes count badge on Remediate queue items (purple #A855F7 theme) - Add delete confirmation warning when removing items with notes - Append remediation notes to Jira ticket descriptions - Add property-based tests for all correctness properties --- ...i-queue-remediation-notes.property.test.js | 337 ++++++++++++++++ ...diation-vendor-validation.property.test.js | 141 +++++++ .../add_queue_remediation_notes_table.js | 49 +++ .../migrations/add_remediate_workflow_type.js | 48 +++ backend/migrations/run-all.js | 2 + backend/routes/ivantiTodoQueue.js | 154 +++++++- ...i-queue-remediation-badge.property.test.js | 79 ++++ ...e-remediation-description.property.test.js | 212 ++++++++++ ...ueue-remediation-grouping.property.test.js | 180 +++++++++ frontend/src/components/RemediationModal.js | 362 ++++++++++++++++++ .../components/pages/IvantiTodoQueuePage.js | 216 ++++++++++- .../src/components/pages/ReportingPage.js | 1 + frontend/src/utils/jiraConsolidation.js | 50 +++ 13 files changed, 1803 insertions(+), 28 deletions(-) create mode 100644 backend/__tests__/ivanti-queue-remediation-notes.property.test.js create mode 100644 backend/__tests__/ivanti-queue-remediation-vendor-validation.property.test.js create mode 100644 backend/migrations/add_queue_remediation_notes_table.js create mode 100644 backend/migrations/add_remediate_workflow_type.js create mode 100644 frontend/src/__tests__/ivanti-queue-remediation-badge.property.test.js create mode 100644 frontend/src/__tests__/ivanti-queue-remediation-description.property.test.js create mode 100644 frontend/src/__tests__/ivanti-queue-remediation-grouping.property.test.js create mode 100644 frontend/src/components/RemediationModal.js diff --git a/backend/__tests__/ivanti-queue-remediation-notes.property.test.js b/backend/__tests__/ivanti-queue-remediation-notes.property.test.js new file mode 100644 index 0000000..c968d28 --- /dev/null +++ b/backend/__tests__/ivanti-queue-remediation-notes.property.test.js @@ -0,0 +1,337 @@ +/** + * Property-Based Tests: Ivanti Queue Remediation — Notes System + * + * Feature: ivanti-queue-remediation + * + * Tests properties 3–7 from the design document: + * - Property 3: Whitespace-only note content is always rejected + * - Property 4: Note creation round-trip + * - Property 5: Notes returned in descending creation order + * - Property 6: Ownership enforcement + * - Property 7: Cascade delete removes all associated notes + * + * These tests validate the pure logic and simulate the API behavior + * without requiring a running database. + */ + +const fc = require('fast-check'); + +// --------------------------------------------------------------------------- +// Simulate the backend validation and data layer logic +// --------------------------------------------------------------------------- + +/** + * Simulates POST /api/ivanti/todo-queue/:id/notes validation logic. + * Returns { accepted: boolean, status: number, error?: string } + */ +function validateNoteCreation(note_text, queueItemExists, isOwner) { + // Ownership check + if (!queueItemExists || !isOwner) { + return { accepted: false, status: 404, error: 'Queue item not found.' }; + } + + // Text validation + if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) { + return { accepted: false, status: 400, error: 'Note text is required.' }; + } + if (note_text.length > 5000) { + return { accepted: false, status: 400, error: 'Note text must not exceed 5000 characters.' }; + } + + return { accepted: true, status: 201 }; +} + +/** + * Simulates a simple in-memory note store for round-trip and ordering tests. + */ +class NoteStore { + constructor() { + this.notes = []; + this.nextId = 1; + this.queueItems = new Map(); // id -> { user_id } + } + + createQueueItem(userId) { + const id = this.nextId++; + this.queueItems.set(id, { user_id: userId }); + return id; + } + + createNote(queueItemId, userId, username, noteText) { + const item = this.queueItems.get(queueItemId); + if (!item) return { error: 'Queue item not found.', status: 404 }; + if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 }; + if (!noteText || typeof noteText !== 'string' || noteText.trim().length === 0) { + return { error: 'Note text is required.', status: 400 }; + } + if (noteText.length > 5000) { + return { error: 'Note text must not exceed 5000 characters.', status: 400 }; + } + + const note = { + id: this.nextId++, + queue_item_id: queueItemId, + user_id: userId, + username, + note_text: noteText, + created_at: new Date().toISOString(), + }; + this.notes.push(note); + return { note, status: 201 }; + } + + getNotes(queueItemId, userId) { + const item = this.queueItems.get(queueItemId); + if (!item) return { error: 'Queue item not found.', status: 404 }; + if (item.user_id !== userId) return { error: 'Queue item not found.', status: 404 }; + + const notes = this.notes + .filter(n => n.queue_item_id === queueItemId) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + return { notes, status: 200 }; + } + + deleteQueueItem(queueItemId) { + this.queueItems.delete(queueItemId); + // Simulate ON DELETE CASCADE + this.notes = this.notes.filter(n => n.queue_item_id !== queueItemId); + } + + getNotesForItem(queueItemId) { + return this.notes.filter(n => n.queue_item_id === queueItemId); + } +} + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +// Whitespace-only strings (including empty) +const arbWhitespaceOnly = fc.oneof( + fc.constant(''), + fc.array(fc.constantFrom(' ', '\t', '\n', '\r', '\f'), { minLength: 1, maxLength: 50 }) + .map(arr => arr.join('')) +); + +// Valid note text: 1–5000 chars, at least one non-whitespace +const arbValidNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0); + +// Over-length note text +const arbOverlengthNoteText = fc.string({ minLength: 5001, maxLength: 5100 }); + +// User IDs (positive integers) +const arbUserId = fc.integer({ min: 1, max: 10000 }); + +// Usernames +const arbUsername = fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0); + +// Number of notes to create (for ordering test) +const arbNoteCount = fc.integer({ min: 2, max: 10 }); + +// --------------------------------------------------------------------------- +// Property 3: Whitespace-only note content is always rejected +// **Validates: Requirements 3.5, 4.4, 5.8** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 3: Whitespace-only note content is always rejected', () => { + it('rejects any string composed entirely of whitespace characters', () => { + fc.assert( + fc.property(arbWhitespaceOnly, (noteText) => { + const result = validateNoteCreation(noteText, true, true); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 100 } + ); + }); + + it('rejects undefined and null as note_text', () => { + const arbNullish = fc.oneof(fc.constant(undefined), fc.constant(null)); + fc.assert( + fc.property(arbNullish, (noteText) => { + const result = validateNoteCreation(noteText, true, true); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 10 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 4: Note creation round-trip +// **Validates: Requirements 4.1, 3.3** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 4: Note creation round-trip', () => { + it('creates a note and retrieves it with exact same text, correct username, and valid created_at', () => { + fc.assert( + fc.property(arbValidNoteText, arbUserId, arbUsername, (noteText, userId, username) => { + const store = new NoteStore(); + const itemId = store.createQueueItem(userId); + + const createResult = store.createNote(itemId, userId, username, noteText); + expect(createResult.status).toBe(201); + expect(createResult.note.note_text).toBe(noteText); + expect(createResult.note.username).toBe(username); + + const getResult = store.getNotes(itemId, userId); + expect(getResult.status).toBe(200); + expect(getResult.notes.length).toBe(1); + expect(getResult.notes[0].note_text).toBe(noteText); + expect(getResult.notes[0].username).toBe(username); + expect(getResult.notes[0].created_at).toBeTruthy(); + // Verify created_at is a valid ISO timestamp + expect(new Date(getResult.notes[0].created_at).toString()).not.toBe('Invalid Date'); + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 5: Notes returned in descending creation order +// **Validates: Requirements 4.2** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 5: Notes returned in descending creation order', () => { + it('for N >= 2 notes, GET returns them with created_at in descending order', () => { + fc.assert( + fc.property(arbNoteCount, arbUserId, arbUsername, (count, userId, username) => { + const store = new NoteStore(); + const itemId = store.createQueueItem(userId); + + // Create N notes + for (let i = 0; i < count; i++) { + const result = store.createNote(itemId, userId, username, `Note ${i + 1}`); + expect(result.status).toBe(201); + } + + const getResult = store.getNotes(itemId, userId); + expect(getResult.status).toBe(200); + expect(getResult.notes.length).toBe(count); + + // Verify descending order + for (let i = 0; i < getResult.notes.length - 1; i++) { + const current = new Date(getResult.notes[i].created_at); + const next = new Date(getResult.notes[i + 1].created_at); + expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime()); + } + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 6: Ownership enforcement +// **Validates: Requirements 4.3** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 6: Ownership enforcement', () => { + it('returns 404 when user B attempts to create notes on user A queue item', () => { + fc.assert( + fc.property( + arbUserId, + arbUserId.filter(id => id > 1), // ensure we can generate different users + arbValidNoteText, + arbUsername, + (userA, userBOffset, noteText, username) => { + const userB = userA + userBOffset; // guarantee different user + const store = new NoteStore(); + const itemId = store.createQueueItem(userA); + + const result = store.createNote(itemId, userB, username, noteText); + expect(result.status).toBe(404); + expect(result.error).toBe('Queue item not found.'); + } + ), + { numRuns: 100 } + ); + }); + + it('returns 404 when user B attempts to get notes on user A queue item', () => { + fc.assert( + fc.property( + arbUserId, + arbUserId.filter(id => id > 1), + arbValidNoteText, + arbUsername, + (userA, userBOffset, noteText, username) => { + const userB = userA + userBOffset; + const store = new NoteStore(); + const itemId = store.createQueueItem(userA); + + // User A creates a note + store.createNote(itemId, userA, username, noteText); + + // User B tries to read + const result = store.getNotes(itemId, userB); + expect(result.status).toBe(404); + expect(result.error).toBe('Queue item not found.'); + } + ), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 7: Cascade delete removes all associated notes +// **Validates: Requirements 3.4, 7.3** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 7: Cascade delete removes all associated notes', () => { + it('deleting a queue item removes all its associated notes', () => { + fc.assert( + fc.property( + arbNoteCount, + arbUserId, + arbUsername, + (count, userId, username) => { + const store = new NoteStore(); + const itemId = store.createQueueItem(userId); + + // Create N notes + for (let i = 0; i < count; i++) { + store.createNote(itemId, userId, username, `Remediation step ${i + 1}`); + } + + // Verify notes exist + expect(store.getNotesForItem(itemId).length).toBe(count); + + // Delete the queue item (simulates CASCADE) + store.deleteQueueItem(itemId); + + // Verify zero notes remain + expect(store.getNotesForItem(itemId).length).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('deleting a queue item does not affect notes for other queue items', () => { + fc.assert( + fc.property( + arbNoteCount, + arbUserId, + arbUsername, + (count, userId, username) => { + const store = new NoteStore(); + const itemA = store.createQueueItem(userId); + const itemB = store.createQueueItem(userId); + + // Create notes for both items + for (let i = 0; i < count; i++) { + store.createNote(itemA, userId, username, `Note A-${i}`); + store.createNote(itemB, userId, username, `Note B-${i}`); + } + + // Delete item A + store.deleteQueueItem(itemA); + + // Item B notes are unaffected + expect(store.getNotesForItem(itemB).length).toBe(count); + expect(store.getNotesForItem(itemA).length).toBe(0); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/__tests__/ivanti-queue-remediation-vendor-validation.property.test.js b/backend/__tests__/ivanti-queue-remediation-vendor-validation.property.test.js new file mode 100644 index 0000000..9023d09 --- /dev/null +++ b/backend/__tests__/ivanti-queue-remediation-vendor-validation.property.test.js @@ -0,0 +1,141 @@ +/** + * Property-Based Tests: Ivanti Queue Remediation — Vendor Validation + * + * Feature: ivanti-queue-remediation + * Property 1: Remediate vendor validation + * + * For any non-empty string of 1–200 characters (trimmed, with at least one + * non-whitespace character), submitting it as the vendor field with workflow_type + * "Remediate" to the queue API SHALL be accepted; and for any empty, + * whitespace-only, or >200 character vendor string, the request SHALL be + * rejected with a 400 status. + * + * **Validates: Requirements 1.2, 1.3** + */ + +const fc = require('fast-check'); + +// --------------------------------------------------------------------------- +// Replicate the pure validation logic from ivantiTodoQueue.js +// --------------------------------------------------------------------------- + +const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; +const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; + +function isValidVendor(vendor) { + if (typeof vendor !== 'string') return false; + const trimmed = vendor.trim(); + return trimmed.length > 0 && trimmed.length <= 200; +} + +/** + * Simulates the validation logic for the batch/single add endpoints. + * Returns { accepted: boolean, status: number } mirroring the route behavior. + */ +function validateRemediateRequest(vendor) { + const workflow_type = 'Remediate'; + + if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { + return { accepted: false, status: 400 }; + } + + // Remediate is NOT in INVENTORY_TYPES, so vendor is required + if (!INVENTORY_TYPES.includes(workflow_type)) { + if (!isValidVendor(vendor)) { + return { accepted: false, status: 400 }; + } + } + + return { accepted: true, status: 201 }; +} + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +// Valid vendor: 1–200 chars trimmed, at least one non-whitespace char +const arbValidVendor = fc.string({ minLength: 1, maxLength: 200 }).filter(s => { + const trimmed = s.trim(); + return trimmed.length > 0 && trimmed.length <= 200; +}); + +// Invalid vendor: empty string +const arbEmptyVendor = fc.constant(''); + +// Invalid vendor: whitespace-only strings +const arbWhitespaceOnlyVendor = fc.array( + fc.constantFrom(' ', '\t', '\n', '\r'), + { minLength: 1, maxLength: 50 } +).map(arr => arr.join('')); + +// Invalid vendor: strings > 200 chars when trimmed +const arbOverlengthVendor = fc.string({ minLength: 201, maxLength: 400 }).filter(s => { + return s.trim().length > 200; +}); + +// --------------------------------------------------------------------------- +// Property 1: Remediate vendor validation +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 1: Remediate vendor validation', () => { + it('accepts any non-empty vendor string of 1–200 trimmed characters with at least one non-whitespace', () => { + fc.assert( + fc.property(arbValidVendor, (vendor) => { + const result = validateRemediateRequest(vendor); + expect(result.accepted).toBe(true); + expect(result.status).toBe(201); + }), + { numRuns: 100 } + ); + }); + + it('rejects empty string as vendor for Remediate workflow', () => { + fc.assert( + fc.property(arbEmptyVendor, (vendor) => { + const result = validateRemediateRequest(vendor); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 10 } + ); + }); + + it('rejects whitespace-only strings as vendor for Remediate workflow', () => { + fc.assert( + fc.property(arbWhitespaceOnlyVendor, (vendor) => { + const result = validateRemediateRequest(vendor); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 100 } + ); + }); + + it('rejects vendor strings exceeding 200 characters when trimmed', () => { + fc.assert( + fc.property(arbOverlengthVendor, (vendor) => { + const result = validateRemediateRequest(vendor); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 100 } + ); + }); + + it('rejects non-string vendor values (undefined, null, number)', () => { + const arbNonString = fc.oneof( + fc.constant(undefined), + fc.constant(null), + fc.integer(), + fc.boolean() + ); + + fc.assert( + fc.property(arbNonString, (vendor) => { + const result = validateRemediateRequest(vendor); + expect(result.accepted).toBe(false); + expect(result.status).toBe(400); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/migrations/add_queue_remediation_notes_table.js b/backend/migrations/add_queue_remediation_notes_table.js new file mode 100644 index 0000000..1a1cace --- /dev/null +++ b/backend/migrations/add_queue_remediation_notes_table.js @@ -0,0 +1,49 @@ +// Migration: Create queue_remediation_notes table +// Stores remediation notes for Ivanti todo queue items (append-only). +// FK cascade ensures notes are deleted when the parent queue item is removed. +// Idempotent — safe to re-run multiple times. +const pool = require('../db'); + +async function run() { + console.log('Starting queue_remediation_notes migration...'); + + // Verify prerequisite table exists + const { rows: queueTable } = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue' + `); + if (queueTable.length === 0) { + console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.'); + process.exit(1); + } + console.log('✓ ivanti_todo_queue table exists'); + + // Create queue_remediation_notes table + await pool.query(` + CREATE TABLE IF NOT EXISTS queue_remediation_notes ( + id SERIAL PRIMARY KEY, + queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL, + username VARCHAR(100) NOT NULL, + note_text TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT chk_note_text_length CHECK (char_length(note_text) <= 5000) + ) + `); + console.log('✓ queue_remediation_notes table created (or already exists)'); + + // Create index on queue_item_id for efficient lookup + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_remediation_notes_queue_item + ON queue_remediation_notes(queue_item_id) + `); + console.log('✓ queue_item_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); +}); diff --git a/backend/migrations/add_remediate_workflow_type.js b/backend/migrations/add_remediate_workflow_type.js new file mode 100644 index 0000000..cc0b29d --- /dev/null +++ b/backend/migrations/add_remediate_workflow_type.js @@ -0,0 +1,48 @@ +// Migration: Add 'Remediate' to the ivanti_todo_queue workflow_type constraint +// Uses idempotent pattern: drop constraint IF EXISTS, then re-add with full set. +// Safe to re-run multiple times. +const pool = require('../db'); + +async function run() { + console.log('Starting add_remediate_workflow_type migration...'); + + // Verify prerequisite table exists + const { rows: queueTable } = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue' + `); + if (queueTable.length === 0) { + console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.'); + process.exit(1); + } + console.log('✓ ivanti_todo_queue table exists'); + + // Drop the existing workflow_type check constraint if it exists + await pool.query(` + ALTER TABLE ivanti_todo_queue + DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check + `); + console.log('✓ Dropped existing workflow_type check constraint (if any)'); + + // Also drop alternative constraint name patterns + await pool.query(` + ALTER TABLE ivanti_todo_queue + DROP CONSTRAINT IF EXISTS chk_workflow_type + `); + + // Re-add the constraint with 'Remediate' included + await pool.query(` + ALTER TABLE ivanti_todo_queue + ADD CONSTRAINT ivanti_todo_queue_workflow_type_check + CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate')) + `); + console.log('✓ Added workflow_type check constraint with Remediate included'); + + console.log('Migration complete.'); + process.exit(0); +} + +run().catch(err => { + console.error('Migration failed:', err.message); + process.exit(1); +}); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 18d267f..d337612 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -27,6 +27,8 @@ const POSTGRES_MIGRATIONS = [ 'drop_jira_status_check_constraint.js', 'add_compliance_history_metric_id.js', 'add_archer_templates_table.js', + 'add_queue_remediation_notes_table.js', + 'add_remediate_workflow_type.js', ]; async function runAll() { diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 534d554..3c3c054 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -4,7 +4,7 @@ const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); -const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM']; +const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; const VALID_STATUSES = ['pending', 'complete']; @@ -32,16 +32,22 @@ function createIvantiTodoQueueRouter() { * - ip_address {string|null} * - hostname {string|null} * - vendor {string} - * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM + * - workflow_type {string} One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - status {string} pending | complete + * - remediation_notes_count {number} * - created_at {string} * - updated_at {string} */ router.get('/', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( - `SELECT q.* + `SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count FROM ivanti_todo_queue q + LEFT JOIN ( + SELECT queue_item_id, COUNT(*) AS note_count + FROM queue_remediation_notes + GROUP BY queue_item_id + ) nc ON nc.queue_item_id = q.id WHERE q.user_id = $1 ORDER BY q.vendor ASC, q.created_at ASC`, [req.user.id] @@ -51,7 +57,7 @@ function createIvantiTodoQueueRouter() { if (r.cves_json) { try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; } } - return { ...r, cves }; + return { ...r, remediation_notes_count: parseInt(r.remediation_notes_count, 10), cves }; }); res.json(parsed); } catch (err) { @@ -73,8 +79,8 @@ function createIvantiTodoQueueRouter() { * - cves {Array} Optional * - ip_address {string} Optional, max 64 chars * - hostname {string} Optional, max 255 chars - * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM - * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate + * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars * @returns {Object} { items: Array } — inserted queue items with parsed `cves` array * @error 400 Invalid input * @error 500 Internal server error @@ -94,12 +100,12 @@ function createIvantiTodoQueueRouter() { } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { - return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); + return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' }); } } @@ -184,8 +190,8 @@ function createIvantiTodoQueueRouter() { * - cves {Array} Optional * - ip_address {string} Optional, max 64 chars * - hostname {string} Optional, max 255 chars - * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars - * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM + * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * @returns {Object} The created queue item with parsed `cves` array * @error 400 Invalid input * @error 500 Internal server error @@ -197,10 +203,10 @@ function createIvantiTodoQueueRouter() { return res.status(400).json({ error: 'finding_id is required.' }); } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type) && !isValidVendor(vendor)) { - return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); + return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' }); } if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); @@ -242,7 +248,7 @@ function createIvantiTodoQueueRouter() { * @param {string} id — Queue item ID (URL parameter) * @body {Object} At least one field required: * - vendor {string} Optional, non-empty, max 200 chars - * - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM + * - workflow_type {string} Optional. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate * - status {string} Optional. One of: pending, complete * @returns {Object} The updated queue item with parsed `cves` array * @error 400 Invalid input or no fields to update @@ -257,7 +263,7 @@ function createIvantiTodoQueueRouter() { return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' }); } if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: 'status must be pending or complete.' }); @@ -325,8 +331,8 @@ function createIvantiTodoQueueRouter() { * * @param {string} id — Queue item ID (URL parameter) * @body {Object} - * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM - * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate + * - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars * @returns {Object} The updated or newly created queue item with parsed `cves` array * @error 400 Invalid input * @error 404 Queue item not found @@ -337,11 +343,11 @@ function createIvantiTodoQueueRouter() { const { workflow_type, vendor } = req.body; if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } if (!INVENTORY_TYPES.includes(workflow_type)) { if (!isValidVendor(vendor)) { - return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); + return res.status(400).json({ error: 'vendor is required for FP, Archer, and Remediate workflows.' }); } } if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) { @@ -545,6 +551,118 @@ function createIvantiTodoQueueRouter() { } }); + // ========================================================================= + // Remediation Notes Routes + // ========================================================================= + + /** + * POST /api/ivanti/todo-queue/:id/notes + * + * Creates a remediation note for a queue item owned by the authenticated user. + * Requires Admin or Standard_User group. + * + * @param {string} id — Queue item ID (URL parameter) + * @body {Object} + * - note_text {string} Required, 1–5000 characters, non-whitespace-only + * @returns {Object} The created note with id, queue_item_id, user_id, username, note_text, created_at + * @error 400 Invalid note_text + * @error 404 Queue item not found or not owned + * @error 500 Internal server error + */ + router.post('/:id/notes', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + const { id } = req.params; + const { note_text } = req.body; + + // Validate queue item exists and belongs to user + try { + const { rows: itemRows } = await pool.query( + 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + if (!itemRows[0]) { + return res.status(404).json({ error: 'Queue item not found.' }); + } + } catch (err) { + console.error('Error checking queue item ownership:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + + // Validate note_text + if (!note_text || typeof note_text !== 'string' || note_text.trim().length === 0) { + return res.status(400).json({ error: 'Note text is required.' }); + } + if (note_text.length > 5000) { + return res.status(400).json({ error: 'Note text must not exceed 5000 characters.' }); + } + + try { + const { rows } = await pool.query( + `INSERT INTO queue_remediation_notes (queue_item_id, user_id, username, note_text) + VALUES ($1, $2, $3, $4) + RETURNING id, queue_item_id, user_id, username, note_text, created_at`, + [id, req.user.id, req.user.username, note_text] + ); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'create_remediation_note', + entityType: 'queue_remediation_notes', + entityId: String(rows[0].id), + details: { queue_item_id: parseInt(id, 10) }, + ipAddress: req.ip, + }); + + return res.status(201).json(rows[0]); + } catch (err) { + console.error('Error creating remediation note:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + }); + + /** + * GET /api/ivanti/todo-queue/:id/notes + * + * Returns all remediation notes for a queue item owned by the authenticated user. + * Notes are ordered by created_at descending (most recent first). + * + * @param {string} id — Queue item ID (URL parameter) + * @returns {Array} Array of note objects (empty array if none) + * @error 404 Queue item not found or not owned + * @error 500 Internal server error + */ + router.get('/:id/notes', requireAuth(), async (req, res) => { + const { id } = req.params; + + // Validate queue item exists and belongs to user + try { + const { rows: itemRows } = await pool.query( + 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + if (!itemRows[0]) { + return res.status(404).json({ error: 'Queue item not found.' }); + } + } catch (err) { + console.error('Error checking queue item ownership:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + + try { + const { rows } = await pool.query( + `SELECT id, queue_item_id, user_id, username, note_text, created_at + FROM queue_remediation_notes + WHERE queue_item_id = $1 + ORDER BY created_at DESC`, + [id] + ); + return res.json(rows); + } catch (err) { + console.error('Error fetching remediation notes:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + }); + return router; } diff --git a/frontend/src/__tests__/ivanti-queue-remediation-badge.property.test.js b/frontend/src/__tests__/ivanti-queue-remediation-badge.property.test.js new file mode 100644 index 0000000..d9fb007 --- /dev/null +++ b/frontend/src/__tests__/ivanti-queue-remediation-badge.property.test.js @@ -0,0 +1,79 @@ +/** + * Property-Based Test: Note Count Badge Formatting + * + * Feature: ivanti-queue-remediation + * Property 12: Note count badge formatting + * + * For any integer count N where N > 0, the badge display SHALL show the string + * representation of N when N <= 99, and "99+" when N > 99. For N = 0, no badge + * SHALL be displayed. + * + * **Validates: Requirements 6.1, 6.2** + */ +import fc from 'fast-check'; + +// --------------------------------------------------------------------------- +// Pure function under test — extracted badge display logic +// --------------------------------------------------------------------------- + +/** + * Determines the badge display value for a given note count. + * Returns null when no badge should be shown (count = 0). + * + * @param {number} count - The number of remediation notes + * @returns {string|null} The badge text, or null if no badge + */ +function formatBadgeCount(count) { + if (count <= 0) return null; + if (count > 99) return '99+'; + return String(count); +} + +// --------------------------------------------------------------------------- +// Property 12: Note count badge formatting +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 12: Note count badge formatting', () => { + it('displays the exact count for N where 1 <= N <= 99', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 99 }), + (count) => { + const badge = formatBadgeCount(count); + expect(badge).toBe(String(count)); + } + ), + { numRuns: 100 } + ); + }); + + it('displays "99+" for any count exceeding 99', () => { + fc.assert( + fc.property( + fc.integer({ min: 100, max: 100000 }), + (count) => { + const badge = formatBadgeCount(count); + expect(badge).toBe('99+'); + } + ), + { numRuns: 100 } + ); + }); + + it('returns null (no badge) for count = 0', () => { + const badge = formatBadgeCount(0); + expect(badge).toBeNull(); + }); + + it('returns null (no badge) for negative counts', () => { + fc.assert( + fc.property( + fc.integer({ min: -1000, max: 0 }), + (count) => { + const badge = formatBadgeCount(count); + expect(badge).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/__tests__/ivanti-queue-remediation-description.property.test.js b/frontend/src/__tests__/ivanti-queue-remediation-description.property.test.js new file mode 100644 index 0000000..3784f3c --- /dev/null +++ b/frontend/src/__tests__/ivanti-queue-remediation-description.property.test.js @@ -0,0 +1,212 @@ +/** + * Property-Based Tests: Ivanti Queue Remediation — Description Generation + * + * Feature: ivanti-queue-remediation + * + * Property 10: Description generation appends remediation notes iff notes exist + * Property 11: Non-Remediate description unchanged + * + * **Validates: Requirements 8.1, 8.2, 8.3, 8.4** + */ +import fc from 'fast-check'; +import { generateConsolidatedDescription, appendRemediationNotes } from '../utils/jiraConsolidation'; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const arbUsername = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0); + +const arbNoteText = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0); + +const arbDate = fc.integer({ min: 1577836800000, max: 1924905600000 }) + .map(ts => new Date(ts).toISOString()); + +const arbNote = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + queue_item_id: fc.integer({ min: 1, max: 10000 }), + user_id: fc.integer({ min: 1, max: 1000 }), + username: arbUsername, + note_text: arbNoteText, + created_at: arbDate, +}); + +const arbQueueItem = fc.record({ + id: fc.integer({ min: 1, max: 10000 }), + finding_id: fc.string({ minLength: 1, maxLength: 20 }), + finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'), + workflow_type: fc.constant('Remediate'), + hostname: fc.constantFrom('server01', 'host-a', 'web-prod'), + ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'), + cves_json: fc.constant(JSON.stringify(['CVE-2024-1234'])), +}); + +const arbNonRemediateItem = fc.record({ + id: fc.integer({ min: 1, max: 10000 }), + finding_id: fc.string({ minLength: 1, maxLength: 20 }), + finding_title: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + vendor: fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'Adobe'), + workflow_type: fc.constantFrom('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'), + hostname: fc.constantFrom('server01', 'host-a', 'web-prod'), + ip_address: fc.constantFrom('10.0.0.1', '192.168.1.5', '172.16.0.10'), + cves_json: fc.constant(JSON.stringify(['CVE-2024-5678'])), +}); + +// --------------------------------------------------------------------------- +// Property 10: Description generation appends remediation notes iff notes exist +// **Validates: Requirements 8.1, 8.2, 8.3** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 10: Description generation appends remediation notes iff notes exist', () => { + it('appends a "Remediation Notes" section when notesMap has at least one note', () => { + fc.assert( + fc.property( + fc.array(arbQueueItem, { minLength: 1, maxLength: 5 }) + .map(items => { + const seen = new Set(); + return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; }); + }) + .filter(items => items.length > 0), + fc.array(arbNote, { minLength: 1, maxLength: 5 }), + (items, notes) => { + const baseDescription = generateConsolidatedDescription(items); + // Map notes to the first item + const notesMap = { [items[0].id]: notes }; + const result = appendRemediationNotes(baseDescription, notesMap); + + // Should contain the remediation notes section + expect(result).toContain('== Remediation Notes =='); + // Should still contain the base description + expect(result).toContain(baseDescription.trim()); + // Each note's text should appear + for (const note of notes) { + expect(result).toContain(note.note_text); + expect(result).toContain(note.username); + } + } + ), + { numRuns: 100 } + ); + }); + + it('notes are listed in chronological order (oldest first) with [YYYY-MM-DD] prefix', () => { + fc.assert( + fc.property( + arbQueueItem, + fc.array(arbNote, { minLength: 2, maxLength: 5 }), + (item, notes) => { + const baseDescription = generateConsolidatedDescription([item]); + const notesMap = { [item.id]: notes }; + const result = appendRemediationNotes(baseDescription, notesMap); + + // Extract the remediation notes section + const section = result.split('== Remediation Notes ==')[1]; + expect(section).toBeDefined(); + + // Verify each note has the [YYYY-MM-DD] format prefix + for (const note of notes) { + const expectedDate = new Date(note.created_at).toISOString().slice(0, 10); + expect(section).toContain(`[${expectedDate}] ${note.username}:`); + } + + // Verify chronological order (oldest first) + const sortedNotes = [...notes].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + let lastIndex = -1; + for (const note of sortedNotes) { + const idx = section.indexOf(note.note_text, lastIndex + 1); + expect(idx).toBeGreaterThan(lastIndex); + lastIndex = idx; + } + } + ), + { numRuns: 100 } + ); + }); + + it('does NOT append a section when notesMap is empty', () => { + fc.assert( + fc.property( + fc.array(arbQueueItem, { minLength: 1, maxLength: 3 }) + .map(items => { + const seen = new Set(); + return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; }); + }) + .filter(items => items.length > 0), + (items) => { + const baseDescription = generateConsolidatedDescription(items); + const result = appendRemediationNotes(baseDescription, {}); + + expect(result).toBe(baseDescription); + expect(result).not.toContain('== Remediation Notes =='); + } + ), + { numRuns: 100 } + ); + }); + + it('does NOT append a section when notesMap has items with empty arrays', () => { + fc.assert( + fc.property( + arbQueueItem, + (item) => { + const baseDescription = generateConsolidatedDescription([item]); + const notesMap = { [item.id]: [] }; + const result = appendRemediationNotes(baseDescription, notesMap); + + expect(result).toBe(baseDescription); + expect(result).not.toContain('== Remediation Notes =='); + } + ), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 11: Non-Remediate description unchanged +// **Validates: Requirements 8.4** +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 11: Non-Remediate description unchanged', () => { + it('output is identical to generateConsolidatedDescription when no notes exist', () => { + fc.assert( + fc.property( + fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 5 }) + .map(items => { + const seen = new Set(); + return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; }); + }) + .filter(items => items.length > 0), + (items) => { + const baseDescription = generateConsolidatedDescription(items); + // Empty notesMap — simulates non-Remediate items + const result = appendRemediationNotes(baseDescription, {}); + + expect(result).toBe(baseDescription); + expect(result).not.toContain('Remediation Notes'); + } + ), + { numRuns: 100 } + ); + }); + + it('output is identical when notesMap is null or undefined', () => { + fc.assert( + fc.property( + fc.array(arbNonRemediateItem, { minLength: 1, maxLength: 3 }) + .map(items => { + const seen = new Set(); + return items.filter(i => { if (seen.has(i.id)) return false; seen.add(i.id); return true; }); + }) + .filter(items => items.length > 0), + fc.oneof(fc.constant(null), fc.constant(undefined)), + (items, notesMap) => { + const baseDescription = generateConsolidatedDescription(items); + const result = appendRemediationNotes(baseDescription, notesMap); + + expect(result).toBe(baseDescription); + } + ), + { numRuns: 50 } + ); + }); +}); diff --git a/frontend/src/__tests__/ivanti-queue-remediation-grouping.property.test.js b/frontend/src/__tests__/ivanti-queue-remediation-grouping.property.test.js new file mode 100644 index 0000000..111263b --- /dev/null +++ b/frontend/src/__tests__/ivanti-queue-remediation-grouping.property.test.js @@ -0,0 +1,180 @@ +/** + * Property-Based Test: Remediate Queue Grouping + * + * Feature: ivanti-queue-remediation + * Property 2: Remediate items grouped into vendor sections, never Inventory + * + * For any queue item with workflow_type "Remediate", the groupQueueItems function + * SHALL place it in a vendor-grouped section (using the item's vendor field, or + * "Unknown" if vendor is empty/null) and SHALL NOT place it in the Inventory section. + * + * **Validates: Requirements 2.1, 2.2, 2.4** + */ +import fc from 'fast-check'; +import { groupQueueItems } from '../utils/queueGrouping'; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const arbVendor = fc.oneof( + fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + fc.constantFrom('Microsoft', 'Cisco', 'Juniper', 'ADTRAN', 'VMware') +); + +const arbEmptyVendor = fc.oneof( + fc.constant(''), + fc.constant(null), + fc.constant(undefined), + fc.constant(' ') // whitespace only +); + +const arbRemediateItemWithVendor = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constant('Remediate'), + vendor: arbVendor, + status: fc.constant('pending'), + finding_id: fc.string({ minLength: 1, maxLength: 20 }), + finding_title: fc.string({ minLength: 1, maxLength: 100 }), +}); + +const arbRemediateItemNoVendor = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constant('Remediate'), + vendor: arbEmptyVendor, + status: fc.constant('pending'), + finding_id: fc.string({ minLength: 1, maxLength: 20 }), + finding_title: fc.string({ minLength: 1, maxLength: 100 }), +}); + +const arbInventoryItem = fc.record({ + id: fc.integer({ min: 100001, max: 200000 }), + workflow_type: fc.constantFrom('CARD', 'GRANITE', 'DECOM'), + vendor: fc.constant(''), + status: fc.constant('pending'), + finding_id: fc.string({ minLength: 1, maxLength: 20 }), + finding_title: fc.string({ minLength: 1, maxLength: 100 }), +}); + +// --------------------------------------------------------------------------- +// Property 2: Remediate items grouped into vendor sections, never Inventory +// --------------------------------------------------------------------------- +describe('Feature: ivanti-queue-remediation, Property 2: Remediate items grouped into vendor sections, never Inventory', () => { + it('Remediate items with a vendor are placed in vendor-grouped sections, never Inventory', () => { + fc.assert( + fc.property( + fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 20 }) + .map(items => { + // Ensure unique IDs + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0), + (items) => { + const sections = groupQueueItems(items); + + // No Inventory section should exist (Remediate items never go there) + const inventorySection = sections.find(s => s.type === 'inventory'); + expect(inventorySection).toBeUndefined(); + + // All items should be in vendor sections + const vendorSections = sections.filter(s => s.type === 'vendor'); + const allGroupedItems = vendorSections.flatMap(s => s.items); + expect(allGroupedItems.length).toBe(items.length); + + // Each item should be in its vendor's section + for (const item of items) { + const expectedVendor = item.vendor?.trim() || 'Unknown'; + const section = vendorSections.find(s => s.label === expectedVendor); + expect(section).toBeDefined(); + expect(section.items.some(i => i.id === item.id)).toBe(true); + } + } + ), + { numRuns: 100 } + ); + }); + + it('Remediate items with empty/null/whitespace-only vendor land in "Unknown" section', () => { + fc.assert( + fc.property( + fc.array(arbRemediateItemNoVendor, { minLength: 1, maxLength: 10 }) + .map(items => { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0), + (items) => { + const sections = groupQueueItems(items); + + // No Inventory section + const inventorySection = sections.find(s => s.type === 'inventory'); + expect(inventorySection).toBeUndefined(); + + // All items should be in the "Unknown" vendor section + const unknownSection = sections.find(s => s.label === 'Unknown'); + expect(unknownSection).toBeDefined(); + expect(unknownSection.items.length).toBe(items.length); + } + ), + { numRuns: 100 } + ); + }); + + it('Remediate items are never placed in the Inventory section even when mixed with inventory items', () => { + fc.assert( + fc.property( + fc.array(arbRemediateItemWithVendor, { minLength: 1, maxLength: 10 }) + .map(items => { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0), + fc.array(arbInventoryItem, { minLength: 1, maxLength: 5 }) + .map(items => { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0), + (remediateItems, inventoryItems) => { + const allItems = [...remediateItems, ...inventoryItems]; + const sections = groupQueueItems(allItems); + + // Inventory section exists (from inventory items) + const inventorySection = sections.find(s => s.type === 'inventory'); + expect(inventorySection).toBeDefined(); + + // No Remediate items in the inventory section + const remediateInInventory = inventorySection.items.filter( + i => i.workflow_type === 'Remediate' + ); + expect(remediateInInventory.length).toBe(0); + + // All Remediate items are in vendor sections + const vendorSections = sections.filter(s => s.type === 'vendor'); + const allVendorItems = vendorSections.flatMap(s => s.items); + for (const item of remediateItems) { + expect(allVendorItems.some(i => i.id === item.id)).toBe(true); + } + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/components/RemediationModal.js b/frontend/src/components/RemediationModal.js new file mode 100644 index 0000000..9fc6192 --- /dev/null +++ b/frontend/src/components/RemediationModal.js @@ -0,0 +1,362 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { X, Loader, AlertCircle, Send, RefreshCw } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Styles — matches dark theme tactical intelligence aesthetic +// --------------------------------------------------------------------------- +const STYLES = { + overlay: { + position: 'fixed', + inset: 0, + zIndex: 100, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + backdrop: { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.7)', + backdropFilter: 'blur(4px)', + }, + content: { + position: 'relative', + background: 'linear-gradient(135deg, #1E293B, #0F172A)', + border: '1px solid rgba(14, 165, 233, 0.25)', + borderRadius: '16px', + padding: '2rem', + width: '90%', + maxWidth: '560px', + maxHeight: '85vh', + overflowY: 'auto', + zIndex: 101, + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: '1.25rem', + }, + title: { + fontFamily: 'monospace', + fontSize: '0.8rem', + fontWeight: 700, + color: '#A855F7', + textTransform: 'uppercase', + letterSpacing: '0.1em', + }, + subtitle: { + fontFamily: 'monospace', + fontSize: '0.7rem', + color: '#94A3B8', + marginTop: '0.35rem', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '400px', + }, + closeBtn: { + background: 'none', + border: 'none', + color: '#64748B', + cursor: 'pointer', + padding: '0.25rem', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + textarea: { + width: '100%', + boxSizing: 'border-box', + minHeight: '100px', + background: 'rgba(15, 23, 42, 0.8)', + border: '1px solid rgba(14, 165, 233, 0.2)', + borderRadius: '8px', + padding: '0.75rem', + color: '#F8FAFC', + fontSize: '0.85rem', + fontFamily: 'monospace', + resize: 'vertical', + outline: 'none', + }, + charCounter: { + fontFamily: 'monospace', + fontSize: '0.6rem', + color: '#64748B', + textAlign: 'right', + marginTop: '0.25rem', + }, + submitBtn: { + padding: '0.5rem 1rem', + borderRadius: '8px', + border: '1px solid rgba(168, 85, 247, 0.4)', + background: 'rgba(168, 85, 247, 0.15)', + color: '#C084FC', + cursor: 'pointer', + fontSize: '0.8rem', + fontWeight: 600, + display: 'inline-flex', + alignItems: 'center', + gap: '0.4rem', + transition: 'all 0.2s', + }, + submitBtnDisabled: { + opacity: 0.4, + cursor: 'not-allowed', + }, + noteItem: { + padding: '0.75rem', + marginBottom: '0.5rem', + background: 'rgba(14, 165, 233, 0.04)', + border: '1px solid rgba(14, 165, 233, 0.1)', + borderRadius: '8px', + }, + noteMeta: { + fontFamily: 'monospace', + fontSize: '0.6rem', + color: '#64748B', + marginBottom: '0.35rem', + display: 'flex', + gap: '0.5rem', + }, + noteText: { + fontFamily: 'monospace', + fontSize: '0.75rem', + color: '#CBD5E1', + lineHeight: 1.5, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + error: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0.75rem', + background: 'rgba(239, 68, 68, 0.08)', + border: '1px solid rgba(239, 68, 68, 0.2)', + borderRadius: '0.5rem', + marginBottom: '0.75rem', + }, + errorText: { + fontFamily: 'monospace', + fontSize: '0.7rem', + color: '#FCA5A5', + }, + emptyState: { + textAlign: 'center', + padding: '1.5rem 0', + fontFamily: 'monospace', + fontSize: '0.7rem', + color: '#475569', + }, + divider: { + height: '1px', + background: 'rgba(14, 165, 233, 0.1)', + margin: '1rem 0', + }, + retryBtn: { + padding: '0.4rem 0.75rem', + borderRadius: '6px', + border: '1px solid rgba(14, 165, 233, 0.3)', + background: 'rgba(14, 165, 233, 0.1)', + color: '#7DD3FC', + cursor: 'pointer', + fontSize: '0.7rem', + fontWeight: 600, + display: 'inline-flex', + alignItems: 'center', + gap: '0.3rem', + marginTop: '0.5rem', + }, +}; + +// --------------------------------------------------------------------------- +// RemediationModal — add and view remediation notes for a queue item +// --------------------------------------------------------------------------- +export default function RemediationModal({ item, onClose, onNoteAdded }) { + const [notes, setNotes] = useState([]); + const [newNoteText, setNewNoteText] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [fetchError, setFetchError] = useState(null); + + // --------------------------------------------------------------------------- + // Fetch existing notes on mount + // --------------------------------------------------------------------------- + const fetchNotes = useCallback(async () => { + setLoading(true); + setFetchError(null); + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { + credentials: 'include', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + const data = await res.json(); + setNotes(data); + } catch (e) { + setFetchError(e.message || 'Failed to load notes.'); + } finally { + setLoading(false); + } + }, [item.id]); + + useEffect(() => { + fetchNotes(); + }, [fetchNotes]); + + // --------------------------------------------------------------------------- + // Submit new note + // --------------------------------------------------------------------------- + const handleSubmit = useCallback(async () => { + if (!newNoteText.trim() || saving) return; + + setSaving(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ note_text: newNoteText }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || `HTTP ${res.status}`); + } + // Prepend new note to list (most recent first) + setNotes((prev) => [data, ...prev]); + setNewNoteText(''); + if (onNoteAdded) onNoteAdded(); + } catch (e) { + setError(e.message || 'Failed to save note.'); + } finally { + setSaving(false); + } + }, [newNoteText, saving, item.id, onNoteAdded]); + + // --------------------------------------------------------------------------- + // Format date as YYYY-MM-DD + // --------------------------------------------------------------------------- + const formatDate = (dateStr) => { + try { + const d = new Date(dateStr); + return d.toISOString().slice(0, 10); + } catch { + return '—'; + } + }; + + const canSubmit = newNoteText.trim().length > 0 && !saving; + const remaining = 5000 - newNoteText.length; + + return ( +
+
+
+ {/* Header */} +
+
+
Remediation Notes
+
+ {item.finding_title || item.finding_id} +
+
+ ID: {item.finding_id} +
+
+ +
+ + {/* New note input */} +
+