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