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,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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user