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
This commit is contained in:
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
backend/migrations/add_queue_remediation_notes_table.js
Normal file
49
backend/migrations/add_queue_remediation_notes_table.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
48
backend/migrations/add_remediate_workflow_type.js
Normal file
48
backend/migrations/add_remediate_workflow_type.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'drop_jira_status_check_constraint.js',
|
'drop_jira_status_check_constraint.js',
|
||||||
'add_compliance_history_metric_id.js',
|
'add_compliance_history_metric_id.js',
|
||||||
'add_archer_templates_table.js',
|
'add_archer_templates_table.js',
|
||||||
|
'add_queue_remediation_notes_table.js',
|
||||||
|
'add_remediate_workflow_type.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const pool = require('../db');
|
|||||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
const logAudit = require('../helpers/auditLog');
|
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 INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||||
const VALID_STATUSES = ['pending', 'complete'];
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
|
|
||||||
@@ -32,16 +32,22 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* - ip_address {string|null}
|
* - ip_address {string|null}
|
||||||
* - hostname {string|null}
|
* - hostname {string|null}
|
||||||
* - vendor {string}
|
* - 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
|
* - status {string} pending | complete
|
||||||
|
* - remediation_notes_count {number}
|
||||||
* - created_at {string}
|
* - created_at {string}
|
||||||
* - updated_at {string}
|
* - updated_at {string}
|
||||||
*/
|
*/
|
||||||
router.get('/', requireAuth(), async (req, res) => {
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT q.*
|
`SELECT q.*, COALESCE(nc.note_count, 0) AS remediation_notes_count
|
||||||
FROM ivanti_todo_queue q
|
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
|
WHERE q.user_id = $1
|
||||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
@@ -51,7 +57,7 @@ function createIvantiTodoQueueRouter() {
|
|||||||
if (r.cves_json) {
|
if (r.cves_json) {
|
||||||
try { cves = JSON.parse(r.cves_json); } catch (e) { cves = []; }
|
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);
|
res.json(parsed);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -73,8 +79,8 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* - cves {Array<string>} Optional
|
* - cves {Array<string>} Optional
|
||||||
* - ip_address {string} Optional, max 64 chars
|
* - ip_address {string} Optional, max 64 chars
|
||||||
* - hostname {string} Optional, max 255 chars
|
* - hostname {string} Optional, max 255 chars
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
||||||
* @returns {Object} { items: Array<Object> } — inserted queue items with parsed `cves` array
|
* @returns {Object} { items: Array<Object> } — inserted queue items with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
@@ -94,12 +100,12 @@ function createIvantiTodoQueueRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
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 (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||||
if (!isValidVendor(vendor)) {
|
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<string>} Optional
|
* - cves {Array<string>} Optional
|
||||||
* - ip_address {string} Optional, max 64 chars
|
* - ip_address {string} Optional, max 64 chars
|
||||||
* - hostname {string} Optional, max 255 chars
|
* - hostname {string} Optional, max 255 chars
|
||||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and Remediate workflows; max 200 chars
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||||
* @returns {Object} The created queue item with parsed `cves` array
|
* @returns {Object} The created queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
@@ -197,10 +203,10 @@ function createIvantiTodoQueueRouter() {
|
|||||||
return res.status(400).json({ error: 'finding_id is required.' });
|
return res.status(400).json({ error: 'finding_id is required.' });
|
||||||
}
|
}
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
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)) {
|
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)) {
|
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
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)
|
* @param {string} id — Queue item ID (URL parameter)
|
||||||
* @body {Object} At least one field required:
|
* @body {Object} At least one field required:
|
||||||
* - vendor {string} Optional, non-empty, max 200 chars
|
* - 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
|
* - status {string} Optional. One of: pending, complete
|
||||||
* @returns {Object} The updated queue item with parsed `cves` array
|
* @returns {Object} The updated queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input or no fields to update
|
* @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).' });
|
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)) {
|
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)) {
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
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)
|
* @param {string} id — Queue item ID (URL parameter)
|
||||||
* @body {Object}
|
* @body {Object}
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM, Remediate
|
||||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
* - 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
|
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input
|
* @error 400 Invalid input
|
||||||
* @error 404 Queue item not found
|
* @error 404 Queue item not found
|
||||||
@@ -337,11 +343,11 @@ function createIvantiTodoQueueRouter() {
|
|||||||
const { workflow_type, vendor } = req.body;
|
const { workflow_type, vendor } = req.body;
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
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 (!INVENTORY_TYPES.includes(workflow_type)) {
|
||||||
if (!isValidVendor(vendor)) {
|
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) {
|
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<Object>} 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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
362
frontend/src/components/RemediationModal.js
Normal file
362
frontend/src/components/RemediationModal.js
Normal file
@@ -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 (
|
||||||
|
<div style={STYLES.overlay}>
|
||||||
|
<div style={STYLES.backdrop} onClick={onClose} />
|
||||||
|
<div style={STYLES.content}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={STYLES.header}>
|
||||||
|
<div>
|
||||||
|
<div style={STYLES.title}>Remediation Notes</div>
|
||||||
|
<div style={STYLES.subtitle} title={item.finding_title || item.finding_id}>
|
||||||
|
{item.finding_title || item.finding_id}
|
||||||
|
</div>
|
||||||
|
<div style={{ ...STYLES.subtitle, fontSize: '0.6rem', color: '#64748B' }}>
|
||||||
|
ID: {item.finding_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={STYLES.closeBtn} aria-label="Close modal">
|
||||||
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New note input */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<textarea
|
||||||
|
value={newNoteText}
|
||||||
|
onChange={(e) => setNewNoteText(e.target.value)}
|
||||||
|
maxLength={5000}
|
||||||
|
placeholder="Describe what remediation steps were taken…"
|
||||||
|
style={STYLES.textarea}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<div style={STYLES.charCounter}>
|
||||||
|
{remaining} characters remaining
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div style={STYLES.error}>
|
||||||
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={STYLES.errorText}>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{
|
||||||
|
...STYLES.submitBtn,
|
||||||
|
...(canSubmit ? {} : STYLES.submitBtnDisabled),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send style={{ width: '14px', height: '14px' }} />
|
||||||
|
{saving ? 'Saving...' : 'Add Note'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={STYLES.divider} />
|
||||||
|
|
||||||
|
{/* Notes list */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||||
|
<Loader style={{ width: '20px', height: '20px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569', marginTop: '0.5rem' }}>
|
||||||
|
Loading notes...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetchError && !loading && (
|
||||||
|
<div style={STYLES.error}>
|
||||||
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={STYLES.errorText}>{fetchError}</span>
|
||||||
|
<button onClick={fetchNotes} style={STYLES.retryBtn}>
|
||||||
|
<RefreshCw style={{ width: '12px', height: '12px' }} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && notes.length === 0 && (
|
||||||
|
<div style={STYLES.emptyState}>
|
||||||
|
No remediation notes yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !fetchError && notes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div key={note.id} style={STYLES.noteItem}>
|
||||||
|
<div style={STYLES.noteMeta}>
|
||||||
|
<span style={{ color: '#A855F7', fontWeight: 600 }}>{note.username}</span>
|
||||||
|
<span>{formatDate(note.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={STYLES.noteText}>{note.note_text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
|
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText, Trash2 } from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
import LoaderModal from '../LoaderModal';
|
import LoaderModal from '../LoaderModal';
|
||||||
import TemplateSelector from '../TemplateSelector';
|
import TemplateSelector from '../TemplateSelector';
|
||||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
import RemediationModal from '../RemediationModal';
|
||||||
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor, appendRemediationNotes } from '../../utils/jiraConsolidation';
|
||||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||||
|
|
||||||
@@ -315,6 +316,15 @@ export default function IvantiTodoQueuePage() {
|
|||||||
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
||||||
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
||||||
|
|
||||||
|
// Remediation Modal state — tracks which item has the modal open
|
||||||
|
const [remediationModalItem, setRemediationModalItem] = useState(null);
|
||||||
|
|
||||||
|
// Local note counts — allows updating badge without full page reload
|
||||||
|
const [localNoteCounts, setLocalNoteCounts] = useState({});
|
||||||
|
|
||||||
|
// Delete confirmation dialog state (Requirement 7)
|
||||||
|
const [deleteConfirmItem, setDeleteConfirmItem] = useState(null);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data fetching
|
// Data fetching
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -479,7 +489,7 @@ export default function IvantiTodoQueuePage() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
|
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const handleCreateJiraTicket = useCallback(() => {
|
const handleCreateJiraTicket = useCallback(async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
if (selectedIds.size === 1) {
|
if (selectedIds.size === 1) {
|
||||||
@@ -488,11 +498,27 @@ export default function IvantiTodoQueuePage() {
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
setSingleJiraItem(item);
|
setSingleJiraItem(item);
|
||||||
const items = [item];
|
const items = [item];
|
||||||
|
let description = generateConsolidatedDescription(items);
|
||||||
|
|
||||||
|
// If the item is Remediate, fetch its notes and append to description (Requirement 8)
|
||||||
|
if (item.workflow_type === 'Remediate') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const notes = await res.json();
|
||||||
|
if (notes.length > 0) {
|
||||||
|
const notesMap = { [item.id]: notes };
|
||||||
|
description = appendRemediationNotes(description, notesMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_e) { /* best effort — proceed without notes */ }
|
||||||
|
}
|
||||||
|
|
||||||
setSingleJiraForm({
|
setSingleJiraForm({
|
||||||
cve_id: extractFirstCve(items),
|
cve_id: extractFirstCve(items),
|
||||||
vendor: extractCommonVendor(items),
|
vendor: extractCommonVendor(items),
|
||||||
summary: generateConsolidatedSummary(items),
|
summary: generateConsolidatedSummary(items),
|
||||||
description: generateConsolidatedDescription(items),
|
description,
|
||||||
source_context: 'ivanti_queue',
|
source_context: 'ivanti_queue',
|
||||||
project_key: '',
|
project_key: '',
|
||||||
issue_type: '',
|
issue_type: '',
|
||||||
@@ -579,6 +605,39 @@ export default function IvantiTodoQueuePage() {
|
|||||||
setSelectionMode(false);
|
setSelectionMode(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete queue item with confirmation for Remediate items with notes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const initiateDelete = useCallback((item) => {
|
||||||
|
const noteCount = localNoteCounts[item.id] !== undefined
|
||||||
|
? localNoteCounts[item.id]
|
||||||
|
: (item.remediation_notes_count || 0);
|
||||||
|
if (item.workflow_type === 'Remediate' && noteCount > 0) {
|
||||||
|
setDeleteConfirmItem({ ...item, _noteCount: noteCount });
|
||||||
|
} else {
|
||||||
|
performDelete(item.id);
|
||||||
|
}
|
||||||
|
}, [localNoteCounts]);
|
||||||
|
|
||||||
|
const performDelete = useCallback(async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setQueueItems((prev) => prev.filter((i) => i.id !== id));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting queue item:', e);
|
||||||
|
}
|
||||||
|
setDeleteConfirmItem(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelDelete = useCallback(() => {
|
||||||
|
setDeleteConfirmItem(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Workflow type color helper
|
// Workflow type color helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -589,6 +648,7 @@ export default function IvantiTodoQueuePage() {
|
|||||||
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
||||||
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
||||||
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
||||||
|
case 'Remediate': return { col: '#A855F7', rgb: '168,85,247' };
|
||||||
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -739,7 +799,11 @@ export default function IvantiTodoQueuePage() {
|
|||||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||||
: '';
|
: '';
|
||||||
const isArcherItem = item.workflow_type === 'Archer';
|
const isArcherItem = item.workflow_type === 'Archer';
|
||||||
|
const isRemediateItem = item.workflow_type === 'Remediate';
|
||||||
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||||
|
const noteCount = localNoteCounts[item.id] !== undefined
|
||||||
|
? localNoteCounts[item.id]
|
||||||
|
: (item.remediation_notes_count || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
@@ -823,6 +887,75 @@ export default function IvantiTodoQueuePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Remediation Notes button (Requirement 5.1, 6.1, 6.2) */}
|
||||||
|
{isRemediateItem && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRemediationModalItem(item); }}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid rgba(168, 85, 247, 0.2)',
|
||||||
|
background: 'rgba(168, 85, 247, 0.05)',
|
||||||
|
color: '#C084FC',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
title="View remediation notes"
|
||||||
|
aria-label="Remediation notes"
|
||||||
|
>
|
||||||
|
<FileText style={{ width: '11px', height: '11px' }} />
|
||||||
|
Notes
|
||||||
|
{noteCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.55rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#A855F7',
|
||||||
|
background: 'rgba(168, 85, 247, 0.15)',
|
||||||
|
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '0.05rem 0.3rem',
|
||||||
|
marginLeft: '0.15rem',
|
||||||
|
}}>
|
||||||
|
{noteCount > 99 ? '99+' : noteCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete button for Remediate items (Requirement 7) */}
|
||||||
|
{isRemediateItem && canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); initiateDelete(item); }}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#475569',
|
||||||
|
padding: '0.2rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#EF4444'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#475569'; }}
|
||||||
|
title="Delete queue item"
|
||||||
|
aria-label="Delete queue item"
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||||
{ticketLinks[item.id] && (
|
{ticketLinks[item.id] && (
|
||||||
<a
|
<a
|
||||||
@@ -1099,6 +1232,69 @@ export default function IvantiTodoQueuePage() {
|
|||||||
onClose={() => setShowLoaderModal(false)}
|
onClose={() => setShowLoaderModal(false)}
|
||||||
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
|
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Remediation Notes Modal */}
|
||||||
|
{remediationModalItem && (
|
||||||
|
<RemediationModal
|
||||||
|
item={remediationModalItem}
|
||||||
|
onClose={() => setRemediationModalItem(null)}
|
||||||
|
onNoteAdded={() => {
|
||||||
|
setLocalNoteCounts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[remediationModalItem.id]: (prev[remediationModalItem.id] !== undefined
|
||||||
|
? prev[remediationModalItem.id]
|
||||||
|
: (remediationModalItem.remediation_notes_count || 0)) + 1,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog (Requirement 7) */}
|
||||||
|
{deleteConfirmItem && (
|
||||||
|
<div style={STYLES.modal}>
|
||||||
|
<div style={STYLES.modalBackdrop} onClick={cancelDelete} />
|
||||||
|
<div style={{ ...STYLES.modalContent, maxWidth: '400px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
|
<AlertCircle style={{ width: '20px', height: '20px', color: '#EF4444' }} />
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: 700, color: '#F8FAFC' }}>
|
||||||
|
Delete Queue Item
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1', lineHeight: 1.6, margin: '0 0 1rem 0' }}>
|
||||||
|
This item has <span style={{ color: '#A855F7', fontWeight: 700 }}>{deleteConfirmItem._noteCount}</span> remediation note{deleteConfirmItem._noteCount !== 1 ? 's' : ''}.
|
||||||
|
Deleting this item will <span style={{ color: '#EF4444', fontWeight: 600 }}>permanently delete</span> all associated remediation notes.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={cancelDelete}
|
||||||
|
style={STYLES.btnCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => performDelete(deleteConfirmItem.id)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||||
|
background: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
color: '#FCA5A5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: '14px', height: '14px' }} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1468,6 +1468,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
|||||||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||||
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||||
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
||||||
|
{ key: 'Remediate', col: '#A855F7', rgb: '168,85,247' },
|
||||||
].map(({ key, col, rgb }) => {
|
].map(({ key, col, rgb }) => {
|
||||||
const active = queueForm.workflowType === key;
|
const active = queueForm.workflowType === key;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -90,3 +90,53 @@ export function extractCommonVendor(items) {
|
|||||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||||
return vendors.length === 1 ? vendors[0] : '';
|
return vendors.length === 1 ? vendors[0] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append remediation notes to a Jira ticket description.
|
||||||
|
* Only appends if notesMap contains notes for at least one item.
|
||||||
|
*
|
||||||
|
* @param {string} baseDescription - The standard consolidated description
|
||||||
|
* @param {Object} notesMap - { [queue_item_id]: Array<{username, note_text, created_at}> }
|
||||||
|
* @returns {string} Description with remediation notes appended (or unchanged)
|
||||||
|
*/
|
||||||
|
export function appendRemediationNotes(baseDescription, notesMap) {
|
||||||
|
if (!notesMap || typeof notesMap !== 'object') return baseDescription;
|
||||||
|
|
||||||
|
// Collect all notes from all items, sorted chronologically (oldest first)
|
||||||
|
const allNotes = [];
|
||||||
|
for (const [_itemId, notes] of Object.entries(notesMap)) {
|
||||||
|
if (!Array.isArray(notes)) continue;
|
||||||
|
for (const note of notes) {
|
||||||
|
if (note && note.note_text) {
|
||||||
|
allNotes.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allNotes.length === 0) return baseDescription;
|
||||||
|
|
||||||
|
// Sort chronologically (oldest first)
|
||||||
|
allNotes.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
|
|
||||||
|
let section = '\n== Remediation Notes ==\n';
|
||||||
|
for (const note of allNotes) {
|
||||||
|
const date = formatNoteDate(note.created_at);
|
||||||
|
section += `[${date}] ${note.username}: ${note.note_text}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseDescription + section;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date string as YYYY-MM-DD for note display.
|
||||||
|
* @param {string} dateStr - ISO date string
|
||||||
|
* @returns {string} Formatted date
|
||||||
|
*/
|
||||||
|
function formatNoteDate(dateStr) {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user