/** * Bug Condition Exploration Property Test: Ivanti Queue Clear Completed FK Violation * * Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix) * * BUG CONDITION (from design.md): * isBugCondition(input) returns true when linkedItems.length > 0 * — completed queue items have associated rows in jira_ticket_queue_items * * The current DELETE /completed handler issues a bare: * DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete' * which fails with a FK violation when child rows exist in jira_ticket_queue_items. * * THIS TEST ENCODES THE EXPECTED (FIXED) BEHAVIOR: * The handler should delete junction table references first, then delete * completed queue items, all within a transaction, and return success. * * ON UNFIXED CODE, THIS TEST WILL FAIL: * The current handler does not use transactions or clean up junction rows. * When pool.query receives the bare DELETE and junction rows exist, the mock * simulates the FK violation error that PostgreSQL would throw. * The handler catches the error and returns 500 instead of the expected 200. * * COUNTEREXAMPLE: * "DELETE FROM ivanti_todo_queue fails with FK violation when junction rows * reference completed items" * * **Validates: Requirements 1.1** */ const http = require('http'); const express = require('express'); const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ requireAuth: () => (req, _res, next) => { req.user = { id: 42, username: 'testuser', group: 'Admin' }; next(); }, requireGroup: () => (_req, _res, next) => next(), })); jest.mock('../helpers/auditLog', () => jest.fn()); // Programmable pg pool mock let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 }); const mockClient = { query: jest.fn((text, params) => queryHandler(text, params)), release: jest.fn(), }; const mockPool = { query: jest.fn((text, params) => queryHandler(text, params)), connect: jest.fn(() => Promise.resolve(mockClient)), }; jest.mock('../db', () => mockPool); const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue'); // --- HTTP helper --- function request(server, method, urlPath) { return new Promise((resolve, reject) => { const addr = server.address(); const options = { hostname: '127.0.0.1', port: addr.port, path: urlPath, method, headers: { 'Content-Type': 'application/json' }, }; const req = http.request(options, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const raw = Buffer.concat(chunks).toString(); let body; try { body = JSON.parse(raw); } catch { body = raw; } resolve({ statusCode: res.statusCode, body }); }); }); req.on('error', reject); req.end(); }); } // --- Generators --- /** * Generate a non-empty array of completed queue item IDs (simulating items * that belong to the authenticated user and have status = 'complete'). */ const completedItemIdsArb = fc.array( fc.integer({ min: 1, max: 10000 }), { minLength: 1, maxLength: 10 } ).map(ids => [...new Set(ids)]); // ensure unique IDs /** * Generate junction table links for a subset of completed items. * At least one item MUST have a junction link (this is the bug condition). */ const junctionLinksArb = (itemIds) => { // Generate at least 1 junction link, up to 3 per item return fc.array( fc.record({ jira_ticket_id: fc.integer({ min: 1, max: 5000 }), queue_item_id: fc.constantFrom(...itemIds), }), { minLength: 1, maxLength: Math.min(itemIds.length * 3, 15) } ); }; // --- Test Suite --- describe('Bug Condition Exploration: FK Violation on Clear Completed With Junction Table Links', () => { let app; let server; beforeAll((done) => { app = express(); app.use(express.json()); app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter()); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { jest.clearAllMocks(); mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.query.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 }); }); /** * Property 1: Bug Condition - FK Violation on Clear Completed With Junction Table Links * * For any set of completed queue items where at least one has junction table links, * the FIXED handler should: * 1. Use a transaction (BEGIN/COMMIT) * 2. Delete junction table references first * 3. Delete the completed queue items * 4. Return 200 with { message, deleted: N } * * On UNFIXED code: The handler uses a bare pool.query DELETE which will receive * a FK violation error from our mock (simulating PostgreSQL behavior), causing * the handler to return 500. This test FAILS, confirming the bug exists. * * **Validates: Requirements 1.1** */ it('Property 1: completed items with junction links are deleted successfully (encodes expected fixed behavior)', async () => { await fc.assert( fc.asyncProperty( completedItemIdsArb, fc.context(), async (itemIds, ctx) => { // Generate junction links for these items const junctionLinks = itemIds.map(id => ({ jira_ticket_id: id * 10, queue_item_id: id, })); ctx.log(`Testing with ${itemIds.length} completed items, ${junctionLinks.length} junction links`); ctx.log(`Item IDs: ${JSON.stringify(itemIds)}`); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.query.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); // Configure the query handler to simulate the bug condition: // - If the code uses pool.query with a bare DELETE on ivanti_todo_queue, // simulate the FK violation error (this is what the UNFIXED code does) // - If the code uses a transaction (BEGIN, SELECT, DELETE junction, DELETE queue, COMMIT), // simulate successful execution (this is what the FIXED code should do) const fkViolationError = new Error( 'update or delete on table "ivanti_todo_queue" violates foreign key constraint ' + '"jira_ticket_queue_items_queue_item_id_fkey" on table "jira_ticket_queue_items"' ); fkViolationError.code = '23503'; // PostgreSQL FK violation error code // Handler for pool.query (unfixed code path) mockPool.query.mockImplementation((text, params) => { // The unfixed code issues a bare DELETE — simulate FK violation if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.reject(fkViolationError); } return Promise.resolve({ rows: [], rowCount: 0 }); }); // Handler for client.query (fixed code path — transaction-based) mockClient.query.mockImplementation((text, params) => { if (text === 'BEGIN') { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: itemIds.length, }); } if (text.includes('DELETE FROM jira_ticket_queue_items')) { return Promise.resolve({ rows: [], rowCount: junctionLinks.length, }); } if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.resolve({ rows: [], rowCount: itemIds.length, }); } if (text === 'COMMIT') { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text === 'ROLLBACK') { return Promise.resolve({ rows: [], rowCount: 0 }); } return Promise.resolve({ rows: [], rowCount: 0 }); }); // Make the request const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); // Assert EXPECTED (fixed) behavior: // The handler should return 200 with the correct deleted count expect(res.statusCode).toBe(200); expect(res.body.message).toBe('Completed items cleared.'); expect(res.body.deleted).toBe(itemIds.length); } ), { numRuns: 20, verbose: 2 } ); }); }); // --- Preservation Property Tests --- /** * Property 2: Preservation - Clear Completed Without Junction Table Links * * Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix) * * PRESERVATION GOAL: * Verify that the UNFIXED code already handles non-bug-condition cases correctly. * These tests establish a baseline that must be preserved after the fix is applied. * * NON-BUG-CONDITION CASES: * - Completed items WITHOUT junction table links → DELETE succeeds, returns count * - No completed items exist → returns { deleted: 0 } * - Pending/in-progress items → never touched by the DELETE * * The current handler uses: * pool.query("DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", [req.user.id]) * This works correctly when no FK violations occur (no junction table references). * * EXPECTED OUTCOME: All tests PASS on unfixed code. * * **Validates: Requirements 3.1, 3.2, 3.3** */ describe('Preservation: Clear Completed Without Junction Table Links', () => { let app; let server; beforeAll((done) => { app = express(); app.use(express.json()); app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter()); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { jest.clearAllMocks(); mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.query.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 }); }); /** * Property 2a: Completed items without junction links are all deleted * * For any random count of completed items (1–20) belonging to the user, * when none have junction table links, the DELETE succeeds and returns * the correct count. * * The fixed code uses a transaction (client.query) so we mock client.query * to simulate successful execution without FK violations. * * **Validates: Requirements 3.1** */ it('Property 2a: completed items without junction links are deleted and correct count returned', async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 1, max: 20 }), fc.context(), async (completedCount, ctx) => { ctx.log(`Testing with ${completedCount} completed items (no junction links)`); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); // Generate item IDs const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1); // Mock client.query for the transaction-based handler mockClient.query.mockImplementation((text, params) => { if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 }); if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount }); } if (text.includes('DELETE FROM jira_ticket_queue_items')) { return Promise.resolve({ rows: [], rowCount: 0 }); // no junction links } if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.resolve({ rows: [], rowCount: completedCount }); } if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 }); if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 }); return Promise.resolve({ rows: [], rowCount: 0 }); }); const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); // Assert preservation behavior expect(res.statusCode).toBe(200); expect(res.body.message).toBe('Completed items cleared.'); expect(res.body.deleted).toBe(completedCount); } ), { numRuns: 30, verbose: 2 } ); }); /** * Property 2b: When no completed items exist, returns deleted: 0 * * When the user has no completed items, the SELECT returns empty rows * and the endpoint returns { message: 'Completed items cleared.', deleted: 0 }. * * **Validates: Requirements 3.2** */ it('Property 2b: no completed items returns deleted: 0', async () => { await fc.assert( fc.asyncProperty( fc.constant(null), fc.context(), async (_unused, ctx) => { ctx.log('Testing with 0 completed items'); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); // Mock client.query: SELECT returns empty rows (no completed items) mockClient.query.mockImplementation((text, params) => { if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 }); if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 }); if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 }); return Promise.resolve({ rows: [], rowCount: 0 }); }); const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); expect(res.statusCode).toBe(200); expect(res.body.message).toBe('Completed items cleared.'); expect(res.body.deleted).toBe(0); } ), { numRuns: 5, verbose: 2 } ); }); /** * Property 2c: Pending/in-progress items are never touched * * The SELECT query only fetches items with status = 'complete' for the user. * We verify the query text and parameters to ensure non-complete items * are never affected. * * **Validates: Requirements 3.3** */ it('Property 2c: DELETE only targets complete status for the authenticated user', async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 0, max: 15 }), fc.context(), async (completedCount, ctx) => { ctx.log(`Testing query targeting with ${completedCount} completed items`); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); // Track the queries issued via client const queriesIssued = []; const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1); mockClient.query.mockImplementation((text, params) => { queriesIssued.push({ text, params }); if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 }); if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount }); } if (text.includes('DELETE FROM jira_ticket_queue_items')) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.resolve({ rows: [], rowCount: completedCount }); } if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 }); if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 }); return Promise.resolve({ rows: [], rowCount: 0 }); }); const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); expect(res.statusCode).toBe(200); // Verify the SELECT query targets only complete status for user 42 const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('complete')); expect(selectQueries.length).toBeGreaterThanOrEqual(1); for (const q of selectQueries) { expect(q.text).toMatch(/status\s*=\s*'\s*complete\s*'/i); expect(q.text).toMatch(/user_id\s*=/i); expect(q.params).toContain(42); } } ), { numRuns: 20, verbose: 2 } ); }); /** * Property 2d: Other users' items remain untouched * * The SELECT query is parameterized with req.user.id (mocked as 42). * For any random count of completed items, the query only affects user 42's items. * We verify the user_id parameter is always the authenticated user's ID. * * **Validates: Requirements 3.1, 3.3** */ it('Property 2d: DELETE is scoped to the authenticated user only', async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 1, max: 10 }), fc.context(), async (completedCount, ctx) => { ctx.log(`Testing user isolation with ${completedCount} completed items`); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); const queriesIssued = []; const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1); mockClient.query.mockImplementation((text, params) => { queriesIssued.push({ text, params }); if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 }); if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount }); } if (text.includes('DELETE FROM jira_ticket_queue_items')) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.resolve({ rows: [], rowCount: completedCount }); } if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 }); if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 }); return Promise.resolve({ rows: [], rowCount: 0 }); }); const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); expect(res.statusCode).toBe(200); expect(res.body.deleted).toBe(completedCount); // Verify the SELECT query is scoped to user 42 const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('user_id')); for (const q of selectQueries) { expect(q.params).toContain(42); // req.user.id from mock } } ), { numRuns: 20, verbose: 2 } ); }); /** * Property 2e: Response shape is always { message: string, deleted: number } * * For any random count of completed items (including 0), the response * always has the correct shape with message as a string and deleted as a number. * * **Validates: Requirements 3.1, 3.2** */ it('Property 2e: response shape is { message: string, deleted: number }', async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 0, max: 50 }), fc.context(), async (completedCount, ctx) => { ctx.log(`Testing response shape with deleted count: ${completedCount}`); // Reset mocks for this iteration mockClient.query.mockReset(); mockClient.release.mockReset(); mockPool.connect.mockReset(); mockPool.connect.mockResolvedValue(mockClient); const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1); mockClient.query.mockImplementation((text, params) => { if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 }); if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) { return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount }); } if (text.includes('DELETE FROM jira_ticket_queue_items')) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (text.includes('DELETE FROM ivanti_todo_queue')) { return Promise.resolve({ rows: [], rowCount: completedCount }); } if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 }); if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 }); return Promise.resolve({ rows: [], rowCount: 0 }); }); const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed'); expect(res.statusCode).toBe(200); expect(typeof res.body.message).toBe('string'); expect(typeof res.body.deleted).toBe('number'); expect(res.body.message).toBe('Completed items cleared.'); expect(res.body.deleted).toBe(completedCount); } ), { numRuns: 25, verbose: 2 } ); }); });