Files
cve-dashboard/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js
Jordan Ramos 37c0970102 Fix Clear Completed button failing on queue items with Jira ticket links
The DELETE /completed endpoint failed with a FK violation when completed
queue items had associated rows in jira_ticket_queue_items. Replaced the
bare DELETE query with a transaction that removes junction table references
before deleting the queue items themselves.

Transaction sequence: BEGIN → SELECT completed IDs → DELETE junction rows →
DELETE queue items → COMMIT, with ROLLBACK on error and client release in
finally block.
2026-05-26 14:07:15 -06:00

577 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (120) 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 }
);
});
});