From 7245352496f157f2755060b511bbd7c118f40855 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Mon, 11 May 2026 14:29:50 -0600 Subject: [PATCH] Add FP submissions cleanup: auto-clear approved, dismiss rejected, collapsible section --- .../fp-submissions-cleanup.property.test.js | 93 +++++++ .../__tests__/fp-submissions-cleanup.test.js | 240 ++++++++++++++++++ .../add_fp_submissions_dismissed.js | 17 ++ backend/migrations/run-all.js | 1 + backend/routes/ivantiFpWorkflow.js | 49 ++++ .../src/components/pages/ReportingPage.js | 106 +++++++- 6 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 backend/__tests__/fp-submissions-cleanup.property.test.js create mode 100644 backend/__tests__/fp-submissions-cleanup.test.js create mode 100644 backend/migrations/add_fp_submissions_dismissed.js diff --git a/backend/__tests__/fp-submissions-cleanup.property.test.js b/backend/__tests__/fp-submissions-cleanup.property.test.js new file mode 100644 index 0000000..4d2cc70 --- /dev/null +++ b/backend/__tests__/fp-submissions-cleanup.property.test.js @@ -0,0 +1,93 @@ +/** + * Property-Based Tests: FP Submissions Cleanup + * + * Feature: fp-submissions-cleanup + * + * Tests the pure filtering functions used to determine which FP submissions + * are visible in the Queue Panel and which show the dismiss button. + * + * Validates: Requirements 1.1, 2.1, 2.2, 2.3 + */ + +const fc = require('fast-check'); +const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow'); + +// --- Generators --- + +const lifecycleStatusArb = fc.constantFrom('submitted', 'approved', 'rejected', 'rework', 'resubmitted'); + +const dismissedAtArb = fc.oneof( + fc.constant(null), + fc.date({ min: new Date('2020-01-01'), max: new Date('2030-12-31') }).map(d => d.toISOString()) +); + +const submissionArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + lifecycle_status: lifecycleStatusArb, + dismissed_at: dismissedAtArb, + user_id: fc.integer({ min: 1, max: 1000 }), + ivanti_workflow_batch_id: fc.string({ minLength: 1, maxLength: 20 }) +}); + +const submissionsArrayArb = fc.array(submissionArb, { minLength: 0, maxLength: 50 }); + +// --- Property 1: Submission Visibility Filter --- + +describe('Feature: fp-submissions-cleanup, Property 1: Submission Visibility Filter', () => { + /** + * For any array of FP submission objects with arbitrary lifecycle_status values + * and arbitrary dismissed_at values, filterVisibleSubmissions(submissions) should + * return only submissions where lifecycle_status is NOT "approved" AND dismissed_at + * is null. Additionally, every submission in the input that satisfies both conditions + * must appear in the output, and the output length must be <= input length. + * + * Validates: Requirements 1.1, 2.2, 2.3 + */ + it('returns only non-approved and non-dismissed submissions', () => { + fc.assert( + fc.property(submissionsArrayArb, (submissions) => { + const result = filterVisibleSubmissions(submissions); + + // Output length must be <= input length + expect(result.length).toBeLessThanOrEqual(submissions.length); + + // Every item in the result must be non-approved and non-dismissed + for (const s of result) { + expect(s.lifecycle_status).not.toBe('approved'); + expect(s.dismissed_at).toBeNull(); + } + + // Every input item that satisfies both conditions must appear in the output + const expected = submissions.filter( + s => s.lifecycle_status !== 'approved' && s.dismissed_at == null + ); + expect(result).toEqual(expected); + }), + { numRuns: 100 } + ); + }); +}); + +// --- Property 2: Dismiss Button Visibility Predicate --- + +describe('Feature: fp-submissions-cleanup, Property 2: Dismiss Button Visibility Predicate', () => { + /** + * For any FP submission object with a lifecycle_status value drawn from + * {submitted, approved, rejected, rework, resubmitted} and a dismissed_at value + * (null or timestamp), the dismiss button should be rendered if and only if + * lifecycle_status === 'rejected' AND dismissed_at is null. + * + * Validates: Requirements 2.1 + */ + it('returns true iff status is rejected and dismissed_at is null', () => { + fc.assert( + fc.property(submissionArb, (submission) => { + const result = shouldShowDismissButton(submission); + const expected = submission.lifecycle_status === 'rejected' && submission.dismissed_at == null; + + expect(result).toBe(expected); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/__tests__/fp-submissions-cleanup.test.js b/backend/__tests__/fp-submissions-cleanup.test.js new file mode 100644 index 0000000..8e52d27 --- /dev/null +++ b/backend/__tests__/fp-submissions-cleanup.test.js @@ -0,0 +1,240 @@ +/** + * Unit and Integration Tests: FP Submissions Cleanup + * + * Feature: fp-submissions-cleanup + * + * Tests cover: + * - Dismiss endpoint (happy path, wrong status, ownership check, not found) + * - Filter edge cases (all approved, all dismissed, mixed, empty array) + * - Integration: dismissed submissions remain in DB but are excluded from filtered list + */ + +const http = require('http'); +const express = require('express'); + +// Mock auth middleware to bypass real session checks +jest.mock('../middleware/auth', () => ({ + requireAuth: () => (req, res, next) => { + req.user = { id: 1, username: 'testuser', group: 'Admin' }; + next(); + }, + requireGroup: () => (req, res, next) => next(), +})); + +// Mock audit log as a no-op +jest.mock('../helpers/auditLog', () => jest.fn()); + +// Mock ivantiApi to avoid real network calls +jest.mock('../helpers/ivantiApi', () => ({ + ivantiFormPost: jest.fn(), + ivantiPost: jest.fn(), +})); + +// Mock the db pool +const mockPool = { + query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), +}; +jest.mock('../db', () => mockPool); + +const createIvantiFpWorkflowRouter = require('../routes/ivantiFpWorkflow'); +const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow'); + +// --- HTTP helper --- + +function request(server, method, path, body) { + return new Promise((resolve, reject) => { + const addr = server.address(); + const options = { + hostname: '127.0.0.1', + port: addr.port, + path, + 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 body = Buffer.concat(chunks).toString(); + let json; + try { json = JSON.parse(body); } catch (e) { json = null; } + resolve({ statusCode: res.statusCode, body: json }); + }); + }); + + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +// --- Dismiss Endpoint Tests (Task 8.1) --- + +describe('PATCH /submissions/:id/dismiss', () => { + let app, server; + + beforeAll((done) => { + app = express(); + app.use(express.json()); + app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter()); + server = app.listen(0, '127.0.0.1', done); + }); + + afterAll((done) => { + server.close(done); + }); + + beforeEach(() => { + mockPool.query.mockReset(); + }); + + it('happy path — dismisses a rejected submission owned by the user', async () => { + // First query: SELECT submission + mockPool.query.mockResolvedValueOnce({ + rows: [{ + id: 42, + user_id: 1, + lifecycle_status: 'rejected', + dismissed_at: null, + ivanti_workflow_batch_id: 'WF-100' + }], + }); + // Second query: UPDATE dismissed_at + mockPool.query.mockResolvedValueOnce({ rowCount: 1 }); + + const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ success: true }); + // Verify the UPDATE was called with the correct SQL pattern + expect(mockPool.query).toHaveBeenCalledTimes(2); + const updateCall = mockPool.query.mock.calls[1]; + expect(updateCall[0]).toContain('dismissed_at'); + expect(updateCall[1]).toContain('42'); + }); + + it('returns 404 when submission does not exist', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/999/dismiss'); + + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Submission not found.'); + }); + + it('returns 403 when user does not own the submission', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ + id: 42, + user_id: 99, // different user + lifecycle_status: 'rejected', + dismissed_at: null, + ivanti_workflow_batch_id: 'WF-100' + }], + }); + + const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toBe('You can only dismiss your own submissions.'); + }); + + it('returns 400 when submission is not in rejected status', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ + id: 42, + user_id: 1, + lifecycle_status: 'submitted', + dismissed_at: null, + ivanti_workflow_batch_id: 'WF-100' + }], + }); + + const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss'); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Only rejected submissions can be dismissed.'); + }); +}); + +// --- Filter Edge Cases (Task 8.2) --- + +describe('filterVisibleSubmissions — edge cases', () => { + it('returns empty array when all submissions are approved', () => { + const submissions = [ + { id: 1, lifecycle_status: 'approved', dismissed_at: null }, + { id: 2, lifecycle_status: 'approved', dismissed_at: null }, + { id: 3, lifecycle_status: 'approved', dismissed_at: null }, + ]; + expect(filterVisibleSubmissions(submissions)).toEqual([]); + }); + + it('returns empty array when all submissions are dismissed', () => { + const submissions = [ + { id: 1, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z' }, + { id: 2, lifecycle_status: 'submitted', dismissed_at: '2026-04-15T08:00:00Z' }, + { id: 3, lifecycle_status: 'rework', dismissed_at: '2026-03-20T10:00:00Z' }, + ]; + expect(filterVisibleSubmissions(submissions)).toEqual([]); + }); + + it('returns correct subset for mixed statuses', () => { + const submissions = [ + { id: 1, lifecycle_status: 'approved', dismissed_at: null }, + { id: 2, lifecycle_status: 'rejected', dismissed_at: null }, + { id: 3, lifecycle_status: 'submitted', dismissed_at: '2026-05-01T12:00:00Z' }, + { id: 4, lifecycle_status: 'rework', dismissed_at: null }, + { id: 5, lifecycle_status: 'resubmitted', dismissed_at: null }, + ]; + const result = filterVisibleSubmissions(submissions); + expect(result).toEqual([ + { id: 2, lifecycle_status: 'rejected', dismissed_at: null }, + { id: 4, lifecycle_status: 'rework', dismissed_at: null }, + { id: 5, lifecycle_status: 'resubmitted', dismissed_at: null }, + ]); + }); + + it('returns empty array for empty input', () => { + expect(filterVisibleSubmissions([])).toEqual([]); + }); +}); + +// --- Integration Test (Task 8.3) --- + +describe('Integration: dismissed submissions remain in DB but are excluded from filtered list', () => { + it('dismissed submission is still in the database but excluded by filterVisibleSubmissions', async () => { + // Simulate the full database state after a dismiss operation: + // The submission record still exists with dismissed_at set + const allSubmissionsInDb = [ + { id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 }, + { id: 2, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z', user_id: 1 }, + { id: 3, lifecycle_status: 'approved', dismissed_at: null, user_id: 1 }, + { id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 }, + ]; + + // The dismissed submission (id: 2) is still in the database + const dismissedSubmission = allSubmissionsInDb.find(s => s.id === 2); + expect(dismissedSubmission).toBeDefined(); + expect(dismissedSubmission.dismissed_at).not.toBeNull(); + + // But when we filter for visible submissions, it's excluded + const visibleSubmissions = filterVisibleSubmissions(allSubmissionsInDb); + + // Dismissed submission (id: 2) is NOT in the visible list + expect(visibleSubmissions.find(s => s.id === 2)).toBeUndefined(); + + // Approved submission (id: 3) is also NOT in the visible list + expect(visibleSubmissions.find(s => s.id === 3)).toBeUndefined(); + + // Non-dismissed, non-approved submissions ARE in the visible list + expect(visibleSubmissions).toEqual([ + { id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 }, + { id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 }, + ]); + + // Verify the original array is unchanged (submissions remain in DB) + expect(allSubmissionsInDb.length).toBe(4); + expect(allSubmissionsInDb.find(s => s.id === 2)).toBeDefined(); + }); +}); diff --git a/backend/migrations/add_fp_submissions_dismissed.js b/backend/migrations/add_fp_submissions_dismissed.js new file mode 100644 index 0000000..f581004 --- /dev/null +++ b/backend/migrations/add_fp_submissions_dismissed.js @@ -0,0 +1,17 @@ +// Migration: Add dismissed_at column to ivanti_fp_submissions table +const pool = require('../db'); + +async function run() { + console.log('Starting FP submissions dismissed migration...'); + try { + await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ DEFAULT NULL`); + console.log('✓ dismissed_at column added (or already exists)'); + } catch (err) { + console.error('Error adding dismissed_at column:', err.message); + process.exit(1); + } + console.log('Migration complete.'); + process.exit(0); +} + +run(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index ff37755..e5470b8 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -15,6 +15,7 @@ const MIGRATIONS_DIR = __dirname; // Add new migrations to this list as they're created. const POSTGRES_MIGRATIONS = [ 'add_decom_workflow_type.js', + 'add_fp_submissions_dismissed.js', ]; async function runAll() { diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index 988bfbc..933f634 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -88,6 +88,14 @@ function buildSubmissionHistoryEntry(changeType, details, userId, username) { return { user_id: userId, username: username, change_type: changeType, change_details_json: JSON.stringify(details), created_at: new Date().toISOString() }; } +function filterVisibleSubmissions(submissions) { + return submissions.filter(s => s.lifecycle_status !== 'approved' && s.dismissed_at == null); +} + +function shouldShowDismissButton(submission) { + return submission.lifecycle_status === 'rejected' && submission.dismissed_at == null; +} + // --------------------------------------------------------------------------- // Resolve workflow batch UUID // --------------------------------------------------------------------------- @@ -723,6 +731,45 @@ function createIvantiFpWorkflowRouter() { }); }); + // PATCH /submissions/:id/dismiss — Dismiss a rejected submission + router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { + (async () => { + const submissionId = req.params.id; + + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; + + if (!submission) return res.status(404).json({ error: 'Submission not found.' }); + if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only dismiss your own submissions.' }); + if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be dismissed.' }); + + try { + await pool.query( + `UPDATE ivanti_fp_submissions SET dismissed_at = NOW() WHERE id = $1`, + [submissionId] + ); + } catch (dbErr) { + console.error('Failed to set dismissed_at:', dbErr); + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit({ + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_submission_dismissed', entityType: 'ivanti_workflow', + entityId: String(submission.ivanti_workflow_batch_id), + details: { submissionId }, + ipAddress: req.ip + }); + + res.json({ success: true }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in PATCH /submissions/:id/dismiss:', unexpectedErr); + res.status(500).json({ error: 'Internal server error.' }); + }); + }); + // PATCH /submissions/:id/status — Update lifecycle status router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { @@ -787,3 +834,5 @@ module.exports.isAllowedFileExtension = isAllowedFileExtension; module.exports.validateLifecycleTransition = validateLifecycleTransition; module.exports.mergeFindings = mergeFindings; module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry; +module.exports.filterVisibleSubmissions = filterVisibleSubmissions; +module.exports.shouldShowDismissButton = shouldShowDismissButton; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index a23a2ac..b88d860 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1519,7 +1519,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd // --------------------------------------------------------------------------- // QueuePanel — fixed slide-out panel showing the user's Ivanti queue // --------------------------------------------------------------------------- -function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, cardConfigured, cardTeams, onQueueRefresh }) { +function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, onDismissSubmission, cardConfigured, cardTeams, onQueueRefresh }) { const pendingCount = items.filter((i) => i.status === 'pending').length; const completedCount = items.filter((i) => i.status === 'complete').length; @@ -1568,6 +1568,37 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on setSelectedIds(new Set()); }; + // Submissions section — collapsible state (Task 6) + const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true'); + const [dismissError, setDismissError] = useState(null); + + const toggleSubmissionsCollapsed = () => { + setSubmissionsCollapsed(prev => { + const next = !prev; + try { localStorage.setItem('steam_submissions_collapsed', String(next)); } catch { /* ignore */ } + return next; + }); + }; + + // Dismiss handler (Task 5) + const handleDismiss = async (e, submissionId) => { + e.stopPropagation(); + setDismissError(null); + try { + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submissionId}/dismiss`, { + method: 'PATCH', + credentials: 'include', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setDismissError(data.error || 'Failed to dismiss submission'); + return; + } + if (onDismissSubmission) onDismissSubmission(submissionId); + } catch (err) { + setDismissError('Network error — could not dismiss submission'); + } + }; const handleRedirectSuccess = (newItem) => { if (onRedirectComplete) onRedirectComplete(newItem); setRedirectItem(null); @@ -2475,24 +2506,40 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on {/* Submissions section */} {fpSubmissions && fpSubmissions.length > 0 && (
-
- - Submissions - +
+
+ {submissionsCollapsed + ? + : + } + + Submissions + +
{fpSubmissions.length}
- {fpSubmissions.map((sub) => { + {dismissError && ( +
+ {dismissError} +
+ )} + {!submissionsCollapsed && fpSubmissions.map((sub) => { const lsBadge = lifecycleStatusBadge(sub.lifecycle_status); const findingCount = (() => { try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; } })(); const clickable = canWrite && onEditSubmission; + const showDismiss = sub.lifecycle_status === 'rejected' && !sub.dismissed_at; return (
{sub.lifecycle_status || 'submitted'} + {showDismiss && ( + + )}
); })} @@ -5376,6 +5449,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { }); }, [fpSubmissionsRaw, findings]); + // Filtered submissions for QueuePanel display — hide approved and dismissed + const fpSubmissionsFiltered = useMemo(() => { + return fpSubmissions.filter(s => s.lifecycle_status !== 'approved' && !s.dismissed_at); + }, [fpSubmissions]); + // Queue API helpers const fetchQueue = useCallback(async () => { setQueueLoading(true); @@ -5426,6 +5504,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { fetchFindings(); }, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line + const handleDismissSubmission = useCallback((submissionId) => { + // Optimistically remove the dismissed submission from local state + setFpSubmissions(prev => prev.filter(s => s.id !== submissionId)); + }, []); + const addToQueue = useCallback(async () => { if (!addPopover) return; const { finding } = addPopover; @@ -6413,8 +6496,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { )); }} canWrite={canWrite} - fpSubmissions={fpSubmissions} + fpSubmissions={fpSubmissionsFiltered} onEditSubmission={handleEditSubmission} + onDismissSubmission={handleDismissSubmission} cardConfigured={cardConfigured} cardTeams={cardTeams} onQueueRefresh={fetchQueue}