/** * 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 } ); }); });