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:
Jordan Ramos
2026-06-08 14:07:59 -06:00
parent d4c428248a
commit 79f98414c4
13 changed files with 1803 additions and 28 deletions

View File

@@ -0,0 +1,337 @@
/**
* Property-Based Tests: Ivanti Queue Remediation — Notes System
*
* Feature: ivanti-queue-remediation
*
* Tests properties 37 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: 15000 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 }
);
});
});