Add multi-item Jira ticket creation from Ivanti Queue
Select multiple queue items and create a single consolidated Jira ticket with aggregated summary and description. Adds multi-select mode with checkboxes, floating action bar, consolidation modal, and junction table to track which queue items contributed to each ticket. - Migration: jira_ticket_queue_items junction table - POST /api/jira-tickets/:id/queue-items endpoint - GET /api/ivanti/todo-queue/ticket-links endpoint - ConsolidationModal component with aggregation logic - IvantiTodoQueuePage with selection mode and ticket link badges - Pure utility functions for summary/description generation - 34 tests passing (backend + frontend)
This commit is contained in:
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
|
||||
* Validates: Requirements 6.3, 6.4
|
||||
*/
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 7, username: 'testuser' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock audit log
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the db pool
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require('../db');
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request and return { statusCode, body }.
|
||||
*/
|
||||
function request(server, method, path) {
|
||||
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 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();
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/ivanti/todo-queue/ticket-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();
|
||||
});
|
||||
|
||||
it('returns an empty links object when no associations exist', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ links: {} });
|
||||
});
|
||||
|
||||
it('returns a map of queue_item_id to ticket info', async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
links: {
|
||||
'12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by the authenticated user ID', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql, params] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('q.user_id = $1');
|
||||
expect(params).toEqual([7]);
|
||||
});
|
||||
|
||||
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('jira_ticket_queue_items');
|
||||
expect(sql).toContain('JOIN jira_tickets');
|
||||
expect(sql).toContain('JOIN ivanti_todo_queue');
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Unit Tests: POST /api/jira-tickets/:id/queue-items
|
||||
*
|
||||
* Feature: multi-item-jira-ticket
|
||||
*
|
||||
* Tests the junction endpoint that links queue items to a Jira ticket.
|
||||
* Validates: Requirements 5.3, 6.1, 6.2
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: (...groups) => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock the audit log helper to be a no-op.
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the jiraApi helper
|
||||
jest.mock('../helpers/jiraApi', () => ({
|
||||
isConfigured: false,
|
||||
getRateLimitStatus: jest.fn(() => ({
|
||||
burst: { remaining: 60, limit: 60 },
|
||||
daily: { remaining: 1440, limit: 1440 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const pool = require('../db');
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request to the test server and return { statusCode, body }.
|
||||
*/
|
||||
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 raw = Buffer.concat(chunks).toString();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
||||
resolve({ statusCode: res.statusCode, body: parsed });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/jira-tickets/:id/queue-items', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 400 when queue_item_ids is missing', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids is an empty array', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids is not an array', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: 'not-an-array',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids contains non-integers', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [1, 2.5, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids contains strings', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [1, 'abc', 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Ticket existence check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 404 when jira ticket does not exist', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', {
|
||||
queue_item_ids: [1, 2, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body.error).toBe('Jira ticket not found');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue item existence check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 400 when some queue items do not exist', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [1, 2, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('One or more queue items not found');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Successful linking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 201 with linked_count on success', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist
|
||||
.mockResolvedValueOnce({ rowCount: 3 }); // insert result
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12, 15, 18],
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.message).toBe('Queue items linked to ticket');
|
||||
expect(res.body.ticket_id).toBe(42);
|
||||
expect(res.body.linked_count).toBe(3);
|
||||
});
|
||||
|
||||
it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist
|
||||
.mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate)
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12, 15],
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.linked_count).toBe(1);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist
|
||||
.mockRejectedValueOnce(new Error('Connection lost')); // insert fails
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12],
|
||||
});
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain('Connection lost');
|
||||
});
|
||||
});
|
||||
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Migration: Add multi-item Jira ticket junction table
|
||||
// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items
|
||||
// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id)
|
||||
// - Adds indexes on queue_item_id and jira_ticket_id
|
||||
// Idempotent — safe to run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting multi-item Jira ticket migration...');
|
||||
|
||||
// Verify prerequisite tables exist
|
||||
const { rows: jiraTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||
`);
|
||||
if (jiraTable.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ jira_tickets table exists');
|
||||
|
||||
const { rows: queueTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
||||
`);
|
||||
if (queueTable.length === 0) {
|
||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ ivanti_todo_queue table exists');
|
||||
|
||||
// Create junction table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (jira_ticket_id, queue_item_id)
|
||||
)
|
||||
`);
|
||||
console.log('✓ jira_ticket_queue_items table created (or already exists)');
|
||||
|
||||
// Add index on queue_item_id for efficient lookup of tickets by queue item
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
||||
ON jira_ticket_queue_items(queue_item_id)
|
||||
`);
|
||||
console.log('✓ queue_item_id index created (or already exists)');
|
||||
|
||||
// Add index on jira_ticket_id for efficient lookup of queue items by ticket
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
||||
ON jira_ticket_queue_items(jira_ticket_id)
|
||||
`);
|
||||
console.log('✓ jira_ticket_id index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -23,6 +23,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_compliance_item_history.js',
|
||||
'add_jira_sync_columns_pg.js',
|
||||
'add_flexible_jira_ticket_creation.js',
|
||||
'add_multi_item_jira_ticket.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
@@ -396,6 +396,41 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue/ticket-links
|
||||
*
|
||||
* Returns Jira ticket associations for the current user's queue items.
|
||||
* Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url.
|
||||
*
|
||||
* @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } }
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.get('/ticket-links', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url
|
||||
FROM jira_ticket_queue_items jtqi
|
||||
JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id
|
||||
JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id
|
||||
WHERE q.user_id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const links = {};
|
||||
for (const row of rows) {
|
||||
links[row.queue_item_id] = {
|
||||
ticket_key: row.ticket_key,
|
||||
jira_url: row.jira_url
|
||||
};
|
||||
}
|
||||
|
||||
res.json({ links });
|
||||
} catch (err) {
|
||||
console.error('Error fetching ticket links:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/completed
|
||||
*
|
||||
|
||||
@@ -762,6 +762,90 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Junction table endpoint — link queue items to a Jira ticket
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/:id/queue-items
|
||||
*
|
||||
* Records associations between a Jira ticket and Ivanti queue items that
|
||||
* contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates.
|
||||
*
|
||||
* @param {string} id - Local Jira ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs
|
||||
* @returns {object} 201 - { message, ticket_id, linked_count }
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { queue_item_ids } = req.body;
|
||||
|
||||
// Validate queue_item_ids is a non-empty array of integers
|
||||
if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
||||
}
|
||||
|
||||
for (const qid of queue_item_ids) {
|
||||
if (!Number.isInteger(qid)) {
|
||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the jira_ticket exists
|
||||
const { rows: ticketRows } = await pool.query(
|
||||
'SELECT id FROM jira_tickets WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
if (ticketRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Jira ticket not found' });
|
||||
}
|
||||
|
||||
// Verify all referenced queue items exist
|
||||
const { rows: existingItems } = await pool.query(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
||||
[queue_item_ids]
|
||||
);
|
||||
if (existingItems.length !== queue_item_ids.length) {
|
||||
return res.status(400).json({ error: 'One or more queue items not found' });
|
||||
}
|
||||
|
||||
// Insert rows with ON CONFLICT DO NOTHING
|
||||
const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', ');
|
||||
const params = [id, ...queue_item_ids];
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id)
|
||||
VALUES ${values}
|
||||
ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`,
|
||||
params
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_link_queue_items',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { queue_item_ids, linked_count: rowCount },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Queue items linked to ticket',
|
||||
ticket_id: parseInt(id, 10),
|
||||
linked_count: rowCount
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error linking queue items to Jira ticket:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
576
frontend/src/components/ConsolidationModal.js
Normal file
576
frontend/src/components/ConsolidationModal.js
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
|
||||
import {
|
||||
generateConsolidatedSummary,
|
||||
generateConsolidatedDescription,
|
||||
extractFirstCve,
|
||||
extractCommonVendor,
|
||||
} from '../utils/jiraConsolidation';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
},
|
||||
modal: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '640px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
title: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
color: '#0EA5E9',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#94A3B8',
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
closeBtn: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
section: {
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
color: '#7DD3FC',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.4rem',
|
||||
display: 'block',
|
||||
},
|
||||
input: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.2s',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: 'rgba(239, 68, 68, 0.6)',
|
||||
},
|
||||
textarea: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.2s',
|
||||
},
|
||||
readOnlyBadge: {
|
||||
display: 'inline-block',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: '#0EA5E9',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.25rem 0.6rem',
|
||||
},
|
||||
previewList: {
|
||||
maxHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
},
|
||||
previewItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.4rem 0.6rem',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
previewItemText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
previewTitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
previewHost: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.62rem',
|
||||
color: '#64748B',
|
||||
marginTop: '1px',
|
||||
},
|
||||
removeBtn: {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '4px',
|
||||
color: '#EF4444',
|
||||
cursor: 'pointer',
|
||||
padding: '0.15rem 0.3rem',
|
||||
marginLeft: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
errorMsg: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
color: '#EF4444',
|
||||
marginTop: '0.3rem',
|
||||
},
|
||||
warningMsg: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245, 158, 11, 0.08)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
apiError: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#EF4444',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1.5rem',
|
||||
},
|
||||
cancelBtn: {
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100, 116, 139, 0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
submitBtn: {
|
||||
flex: 1.5,
|
||||
padding: '0.625rem',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid #0EA5E9',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#0EA5E9',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
charCount: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.62rem',
|
||||
color: '#64748B',
|
||||
textAlign: 'right',
|
||||
marginTop: '0.2rem',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ConsolidationModal — Creates a single Jira ticket from multiple selected
|
||||
* Ivanti queue items. Pre-populates summary, description, CVE, and vendor
|
||||
* using aggregation functions.
|
||||
*
|
||||
* Props:
|
||||
* items {Array} — The selected queue items (full objects)
|
||||
* onClose {Function} — Close handler
|
||||
* onSuccess {Function} — Called with created ticket data on success
|
||||
*/
|
||||
export default function ConsolidationModal({ items, onClose, onSuccess }) {
|
||||
// Internal state — copy of items that can be modified (items removed)
|
||||
const [selectedItems, setSelectedItems] = useState(items);
|
||||
|
||||
// Form fields
|
||||
const [summary, setSummary] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [cveId, setCveId] = useState('');
|
||||
const [vendor, setVendor] = useState('');
|
||||
|
||||
// Locked source context
|
||||
const sourceContext = 'ivanti_queue';
|
||||
|
||||
// UI state
|
||||
const [summaryError, setSummaryError] = useState(null);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize / regenerate form fields when selectedItems changes
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (selectedItems.length >= 2) {
|
||||
setSummary(generateConsolidatedSummary(selectedItems));
|
||||
setDescription(generateConsolidatedDescription(selectedItems));
|
||||
setCveId(extractFirstCve(selectedItems));
|
||||
setVendor(extractCommonVendor(selectedItems));
|
||||
}
|
||||
}, []); // Only on mount — user edits are preserved after item removal
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove an item from the selection (minimum 2 required)
|
||||
// ---------------------------------------------------------------------------
|
||||
const removeItem = useCallback((itemId) => {
|
||||
setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Escape key handler
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form validation
|
||||
// ---------------------------------------------------------------------------
|
||||
const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit handler
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async () => {
|
||||
// Validate summary
|
||||
if (!summary.trim()) {
|
||||
setSummaryError('Summary is required.');
|
||||
return;
|
||||
}
|
||||
if (summary.length > 255) {
|
||||
setSummaryError('Summary must be 255 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
setSummaryError(null);
|
||||
setApiError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Step 1: Create the Jira ticket
|
||||
const createPayload = {
|
||||
summary: summary.trim(),
|
||||
description,
|
||||
cve_id: cveId.trim() || null,
|
||||
vendor: vendor.trim() || null,
|
||||
source_context: sourceContext,
|
||||
};
|
||||
|
||||
const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
|
||||
const createData = await createRes.json();
|
||||
|
||||
if (!createRes.ok && createRes.status !== 207) {
|
||||
throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
|
||||
}
|
||||
|
||||
const ticketId = createData.id;
|
||||
|
||||
// Step 2: Link queue items to the ticket via junction endpoint
|
||||
const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
|
||||
});
|
||||
|
||||
if (!linkRes.ok) {
|
||||
const linkData = await linkRes.json();
|
||||
// Ticket was created but linking failed — partial success
|
||||
console.warn('Junction link failed:', linkData.error || linkRes.status);
|
||||
}
|
||||
|
||||
// Success — close modal and notify parent
|
||||
onSuccess?.(createData);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="consolidation-modal-title"
|
||||
style={STYLES.overlay}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
||||
>
|
||||
<div style={STYLES.modal}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<div>
|
||||
<div id="consolidation-modal-title" style={STYLES.title}>
|
||||
<FileText style={{ width: '16px', height: '16px', display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
||||
Create Consolidated Jira Ticket
|
||||
</div>
|
||||
<div style={STYLES.subtitle}>
|
||||
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={STYLES.closeBtn}
|
||||
title="Close"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Minimum items warning */}
|
||||
{selectedItems.length < 2 && (
|
||||
<div style={STYLES.warningMsg}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
||||
At least 2 items are required for consolidation. Add more items or close this modal.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API error */}
|
||||
{apiError && (
|
||||
<div style={STYLES.apiError}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected items preview list */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label}>Selected Items</label>
|
||||
<div style={STYLES.previewList}>
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id} style={STYLES.previewItem}>
|
||||
<div style={STYLES.previewItemText}>
|
||||
<div style={STYLES.previewTitle} title={item.finding_title}>
|
||||
{item.finding_title || item.finding_id || 'Untitled'}
|
||||
</div>
|
||||
<div style={STYLES.previewHost}>
|
||||
{item.hostname || 'No hostname'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
style={STYLES.removeBtn}
|
||||
title="Remove from selection"
|
||||
aria-label={`Remove ${item.finding_title || 'item'}`}
|
||||
disabled={selectedItems.length <= 2}
|
||||
>
|
||||
<X style={{ width: '12px', height: '12px' }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary field (required, max 255 chars) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-summary">
|
||||
Summary <span style={{ color: '#EF4444' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-summary"
|
||||
type="text"
|
||||
value={summary}
|
||||
onChange={(e) => {
|
||||
setSummary(e.target.value);
|
||||
if (summaryError) setSummaryError(null);
|
||||
}}
|
||||
maxLength={255}
|
||||
placeholder="Ticket summary (required)"
|
||||
style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
|
||||
/>
|
||||
<div style={STYLES.charCount}>{summary.length}/255</div>
|
||||
{summaryError && <div style={STYLES.errorMsg}>{summaryError}</div>}
|
||||
</div>
|
||||
|
||||
{/* Description textarea */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="consolidation-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Aggregated description of selected findings"
|
||||
style={STYLES.textarea}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CVE ID (optional) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-cve">
|
||||
CVE ID <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-cve"
|
||||
type="text"
|
||||
value={cveId}
|
||||
onChange={(e) => setCveId(e.target.value)}
|
||||
placeholder="e.g. CVE-2024-12345"
|
||||
style={STYLES.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor (optional) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-vendor">
|
||||
Vendor <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-vendor"
|
||||
type="text"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
placeholder="e.g. Microsoft"
|
||||
style={STYLES.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Context (read-only) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label}>Source Context</label>
|
||||
<span style={STYLES.readOnlyBadge}>{sourceContext}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={STYLES.actions}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={STYLES.cancelBtn}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||
e.currentTarget.style.color = '#CBD5E1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||
e.currentTarget.style.color = '#94A3B8';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
...STYLES.submitBtn,
|
||||
...(!canSubmit ? STYLES.submitBtnDisabled : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (canSubmit) {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.15)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
'Create Ticket'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
902
frontend/src/components/pages/IvantiTodoQueuePage.js
Normal file
902
frontend/src/components/pages/IvantiTodoQueuePage.js
Normal file
@@ -0,0 +1,902 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ConsolidationModal from '../ConsolidationModal';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matches dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
page: {
|
||||
minHeight: '60vh',
|
||||
},
|
||||
card: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
header: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#0EA5E9',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
toolbarLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
},
|
||||
toolbarRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
btn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnActive: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.6)',
|
||||
background: 'rgba(14, 165, 233, 0.25)',
|
||||
color: '#0EA5E9',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 0 12px rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
selectionCount: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.25rem 0.75rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
},
|
||||
tableHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
marginBottom: '0.5rem',
|
||||
},
|
||||
tableHeaderLabel: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
color: '#64748B',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
queueItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.625rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(14, 165, 233, 0.04)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
},
|
||||
queueItemSelected: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.625rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(14, 165, 233, 0.08)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
},
|
||||
checkbox: {
|
||||
accentColor: '#0EA5E9',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
flexShrink: 0,
|
||||
marginTop: '2px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
selectAllCheckbox: {
|
||||
accentColor: '#0EA5E9',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
floatingBar: {
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 0.99))',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 16px rgba(14, 165, 233, 0.1)',
|
||||
zIndex: 50,
|
||||
},
|
||||
floatingBarBadge: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.25rem 0.75rem',
|
||||
},
|
||||
btnSuccess: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||||
background: 'rgba(16, 185, 129, 0.15)',
|
||||
color: '#6EE7B7',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
btnCancel: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.3)',
|
||||
background: 'rgba(148, 163, 184, 0.08)',
|
||||
color: '#94A3B8',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
modal: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalBackdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
modalContent: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '520px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 101,
|
||||
},
|
||||
input: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.85rem',
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IvantiTodoQueuePage — Full-page Ivanti queue with multi-select support
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function IvantiTodoQueuePage() {
|
||||
const { canWrite } = useAuth();
|
||||
|
||||
// Queue data state
|
||||
const [queueItems, setQueueItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Ticket link badges state (Requirement 6.3, 6.4, 6.5)
|
||||
const [ticketLinks, setTicketLinks] = useState({});
|
||||
|
||||
// Selection mode state (Requirement 1.1)
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
|
||||
// Consolidation modal state (Requirement 2.3)
|
||||
const [showConsolidationModal, setShowConsolidationModal] = useState(false);
|
||||
|
||||
// Single-item Jira creation modal state (Requirement 2.4)
|
||||
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
||||
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
||||
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
|
||||
const [singleJiraError, setSingleJiraError] = useState(null);
|
||||
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
||||
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
const fetchQueue = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
// Parse cves from cves_json if not already parsed
|
||||
const parsed = data.map((item) => {
|
||||
if (item.cves) return item;
|
||||
let cves = [];
|
||||
if (item.cves_json) {
|
||||
try { cves = JSON.parse(item.cves_json); } catch { cves = []; }
|
||||
}
|
||||
return { ...item, cves };
|
||||
});
|
||||
setQueueItems(parsed);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch queue items.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Network error — could not fetch queue items.');
|
||||
console.error('Error fetching queue:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch ticket link associations (Requirements 6.3, 6.4, 6.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
const fetchTicketLinks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/ticket-links`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTicketLinks(data.links || {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching ticket links:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue();
|
||||
fetchTicketLinks();
|
||||
}, [fetchQueue, fetchTicketLinks]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visible items — only pending items are selectable
|
||||
// ---------------------------------------------------------------------------
|
||||
const visibleItems = useMemo(() => {
|
||||
return queueItems.filter((item) => item.status === 'pending');
|
||||
}, [queueItems]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection mode toggle (Requirement 1.1, 1.5)
|
||||
// When deactivated, clear all selections
|
||||
// ---------------------------------------------------------------------------
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode((prev) => {
|
||||
if (prev) {
|
||||
// Deactivating — clear selections (Requirement 1.5)
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual item selection toggle (Requirement 1.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
const toggleItemSelection = useCallback((id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select All toggle (Requirement 1.4)
|
||||
// Toggles all visible (filtered) queue item IDs into/out of selectedIds
|
||||
// ---------------------------------------------------------------------------
|
||||
const allVisibleSelected = useMemo(() => {
|
||||
if (visibleItems.length === 0) return false;
|
||||
return visibleItems.every((item) => selectedIds.has(item.id));
|
||||
}, [visibleItems, selectedIds]);
|
||||
|
||||
const someVisibleSelected = useMemo(() => {
|
||||
if (visibleItems.length === 0) return false;
|
||||
return visibleItems.some((item) => selectedIds.has(item.id)) && !allVisibleSelected;
|
||||
}, [visibleItems, selectedIds, allVisibleSelected]);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (allVisibleSelected) {
|
||||
// Deselect all visible
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleItems.forEach((item) => next.delete(item.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Select all visible
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleItems.forEach((item) => next.add(item.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [allVisibleSelected, visibleItems]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preserve selections on scroll/re-render (Requirement 1.6)
|
||||
// Clean up selectedIds that no longer exist in the queue
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const validIds = new Set(queueItems.map((i) => i.id));
|
||||
const next = new Set([...prev].filter((id) => validIds.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [queueItems]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected queue items (full objects) for modal use
|
||||
// ---------------------------------------------------------------------------
|
||||
const selectedQueueItems = useMemo(() => {
|
||||
return queueItems.filter(item => selectedIds.has(item.id));
|
||||
}, [queueItems, selectedIds]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleCreateJiraTicket = useCallback(() => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
if (selectedIds.size === 1) {
|
||||
// Single item — open single-item Jira creation modal (Requirement 2.4)
|
||||
const item = queueItems.find(i => selectedIds.has(i.id));
|
||||
if (!item) return;
|
||||
setSingleJiraItem(item);
|
||||
const items = [item];
|
||||
setSingleJiraForm({
|
||||
cve_id: extractFirstCve(items),
|
||||
vendor: extractCommonVendor(items),
|
||||
summary: generateConsolidatedSummary(items),
|
||||
description: generateConsolidatedDescription(items),
|
||||
source_context: 'ivanti_queue',
|
||||
});
|
||||
setSingleJiraError(null);
|
||||
setSingleJiraSummaryError(null);
|
||||
setShowSingleJiraModal(true);
|
||||
} else {
|
||||
// Multiple items — open Consolidation Modal (Requirement 2.3)
|
||||
setShowConsolidationModal(true);
|
||||
}
|
||||
}, [selectedIds, queueItems]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consolidation modal success handler
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleConsolidationSuccess = useCallback(() => {
|
||||
setShowConsolidationModal(false);
|
||||
setSelectedIds(new Set());
|
||||
setSelectionMode(false);
|
||||
fetchQueue();
|
||||
fetchTicketLinks();
|
||||
}, [fetchQueue, fetchTicketLinks]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-item Jira creation — submit handler
|
||||
// ---------------------------------------------------------------------------
|
||||
const submitSingleJira = useCallback(async () => {
|
||||
setSingleJiraSummaryError(null);
|
||||
const trimmedSummary = (singleJiraForm.summary || '').trim();
|
||||
if (!trimmedSummary) {
|
||||
setSingleJiraSummaryError('Summary is required.');
|
||||
return;
|
||||
}
|
||||
if (trimmedSummary.length > 255) {
|
||||
setSingleJiraSummaryError('Summary must be 255 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSingleJiraError(null);
|
||||
setSingleJiraSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(singleJiraForm),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok && res.status !== 207) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// If we have a ticket ID and a queue item, link them via junction table
|
||||
if (data.id && singleJiraItem) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/jira-tickets/${data.id}/queue-items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ queue_item_ids: [singleJiraItem.id] }),
|
||||
});
|
||||
} catch (_) { /* junction link is best-effort */ }
|
||||
}
|
||||
|
||||
setShowSingleJiraModal(false);
|
||||
setSingleJiraItem(null);
|
||||
setSelectedIds(new Set());
|
||||
setSelectionMode(false);
|
||||
fetchQueue();
|
||||
fetchTicketLinks();
|
||||
} catch (err) {
|
||||
setSingleJiraError(err.message);
|
||||
} finally {
|
||||
setSingleJiraSaving(false);
|
||||
}
|
||||
}, [singleJiraForm, singleJiraItem, fetchQueue, fetchTicketLinks]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel selection mode from floating bar
|
||||
// ---------------------------------------------------------------------------
|
||||
const cancelSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow type color helper
|
||||
// ---------------------------------------------------------------------------
|
||||
const getWorkflowColor = (workflowType) => {
|
||||
switch (workflowType) {
|
||||
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
|
||||
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
|
||||
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
||||
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
||||
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
||||
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div style={STYLES.page}>
|
||||
<div style={STYLES.card}>
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1.25rem' }}>
|
||||
<ListTodo style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
|
||||
<span style={STYLES.header}>Ivanti Todo Queue</span>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={STYLES.toolbar}>
|
||||
<div style={STYLES.toolbarLeft}>
|
||||
{/* Select toggle button (Requirement 1.1) */}
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={toggleSelectionMode}
|
||||
style={selectionMode ? STYLES.btnActive : STYLES.btn}
|
||||
title={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
|
||||
>
|
||||
{selectionMode
|
||||
? <><CheckSquare style={{ width: '14px', height: '14px' }} /> Selecting</>
|
||||
: <><Square style={{ width: '14px', height: '14px' }} /> Select</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Selection count indicator (Requirement 1.3) */}
|
||||
{selectionMode && selectedIds.size > 0 && (
|
||||
<span style={STYLES.selectionCount}>
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={STYLES.toolbarRight}>
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={fetchQueue}
|
||||
style={STYLES.btn}
|
||||
disabled={loading}
|
||||
title="Refresh queue"
|
||||
>
|
||||
<RefreshCw style={{ width: '14px', height: '14px', animation: loading ? 'spin 1s linear infinite' : 'none' }} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && queueItems.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', marginTop: '0.75rem' }}>
|
||||
Loading queue items...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && queueItems.length === 0 && !error && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<ListTodo style={{ width: '32px', height: '32px', color: '#1E293B', margin: '0 auto' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155', marginTop: '0.75rem' }}>
|
||||
No items in queue.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Queue items table */}
|
||||
{!loading && visibleItems.length > 0 && (
|
||||
<>
|
||||
{/* Table header with Select All (Requirement 1.4) */}
|
||||
<div style={STYLES.tableHeader}>
|
||||
{selectionMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
ref={(el) => { if (el) el.indeterminate = someVisibleSelected; }}
|
||||
onChange={toggleSelectAll}
|
||||
style={STYLES.selectAllCheckbox}
|
||||
title={allVisibleSelected ? 'Deselect all' : 'Select all'}
|
||||
aria-label="Select all queue items"
|
||||
/>
|
||||
)}
|
||||
<span style={{ ...STYLES.tableHeaderLabel, flex: 1 }}>Finding</span>
|
||||
<span style={{ ...STYLES.tableHeaderLabel, width: '80px', textAlign: 'center' }}>Type</span>
|
||||
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Vendor</span>
|
||||
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
|
||||
</div>
|
||||
|
||||
{/* Queue item rows */}
|
||||
{visibleItems.map((item) => {
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
const wfColor = getWorkflowColor(item.workflow_type);
|
||||
const cves = item.cves || [];
|
||||
const cveDisplay = cves.length > 0
|
||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
||||
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
||||
role={selectionMode ? 'button' : undefined}
|
||||
tabIndex={selectionMode ? 0 : undefined}
|
||||
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
|
||||
>
|
||||
{/* Selection checkbox (Requirement 1.2) */}
|
||||
{selectionMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={STYLES.checkbox}
|
||||
aria-label={`Select ${item.finding_title || item.finding_id}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finding info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.finding_title || item.finding_id}>
|
||||
{item.finding_title || item.finding_id}
|
||||
</div>
|
||||
{cveDisplay && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#64748B',
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={cves.join(', ')}>
|
||||
{cveDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||
{ticketLinks[item.id] && (
|
||||
<a
|
||||
href={ticketLinks[item.id].jira_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: '#6EE7B7',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.5rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
|
||||
>
|
||||
{ticketLinks[item.id].ticket_key} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Workflow type badge */}
|
||||
<div style={{
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: wfColor.col,
|
||||
background: `rgba(${wfColor.rgb}, 0.1)`,
|
||||
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
|
||||
borderRadius: '4px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vendor */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.vendor}>
|
||||
{item.vendor || '—'}
|
||||
</div>
|
||||
|
||||
{/* Hostname / IP */}
|
||||
<div style={{
|
||||
width: '120px',
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{item.hostname && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#94A3B8',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}} title={item.hostname}>
|
||||
{item.hostname}
|
||||
</div>
|
||||
)}
|
||||
{item.ip_address && (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.62rem',
|
||||
color: '#10B981',
|
||||
marginTop: item.hostname ? '1px' : 0,
|
||||
}}>
|
||||
{item.ip_address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Completed items count */}
|
||||
{!loading && queueItems.filter(i => i.status === 'complete').length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
color: '#334155',
|
||||
}}>
|
||||
{queueItems.filter(i => i.status === 'complete').length} completed item(s) hidden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar (Requirements 2.1, 2.2) */}
|
||||
{selectionMode && selectedIds.size > 0 && (
|
||||
<div style={STYLES.floatingBar}>
|
||||
<span style={STYLES.floatingBarBadge}>
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCreateJiraTicket}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={selectedIds.size === 0 ? { ...STYLES.btnSuccess, ...STYLES.btnDisabled } : STYLES.btnSuccess}
|
||||
title={selectedIds.size === 1 ? 'Create Jira ticket for selected item' : `Create consolidated Jira ticket for ${selectedIds.size} items`}
|
||||
>
|
||||
<Plus style={{ width: '14px', height: '14px' }} />
|
||||
Create Jira Ticket
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelSelection}
|
||||
style={STYLES.btnCancel}
|
||||
title="Cancel selection"
|
||||
>
|
||||
<X style={{ width: '14px', height: '14px' }} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single-item Jira Creation Modal (Requirement 2.4) */}
|
||||
{showSingleJiraModal && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={() => setShowSingleJiraModal(false)} />
|
||||
<div style={STYLES.modalContent}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Jira Ticket</h3>
|
||||
<button onClick={() => setShowSingleJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X style={{ width: '18px', height: '18px' }} /></button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
|
||||
Create a Jira issue for: <span style={{ color: '#7DD3FC' }}>{singleJiraItem?.finding_title || singleJiraItem?.finding_id}</span>
|
||||
</p>
|
||||
{singleJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{singleJiraError}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
|
||||
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={singleJiraForm.cve_id} onChange={e => setSingleJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
|
||||
<input style={STYLES.input} placeholder="e.g. Microsoft" value={singleJiraForm.vendor} onChange={e => setSingleJiraForm(f => ({ ...f, vendor: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
|
||||
<input
|
||||
style={{ ...STYLES.input, ...(singleJiraSummaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
|
||||
placeholder="Issue summary (max 255 chars)"
|
||||
value={singleJiraForm.summary}
|
||||
onChange={e => { setSingleJiraForm(f => ({ ...f, summary: e.target.value })); if (singleJiraSummaryError) setSingleJiraSummaryError(null); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
{singleJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{singleJiraSummaryError}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
|
||||
<input
|
||||
style={{ ...STYLES.input, opacity: 0.7, cursor: 'not-allowed' }}
|
||||
value="ivanti_queue"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description</label>
|
||||
<textarea
|
||||
style={{ ...STYLES.input, minHeight: '100px', resize: 'vertical' }}
|
||||
placeholder="Detailed description..."
|
||||
value={singleJiraForm.description}
|
||||
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
|
||||
onClick={submitSingleJira}
|
||||
disabled={singleJiraSaving}
|
||||
>
|
||||
{singleJiraSaving ? <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> : <CheckCircle style={{ width: '14px', height: '14px' }} />}
|
||||
Create in Jira
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consolidation Modal (Requirement 2.3) */}
|
||||
{showConsolidationModal && (
|
||||
<ConsolidationModal
|
||||
items={selectedQueueItems}
|
||||
onClose={() => setShowConsolidationModal(false)}
|
||||
onSuccess={handleConsolidationSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/utils/jiraConsolidation.js
Normal file
92
frontend/src/utils/jiraConsolidation.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pure utility functions for consolidating multiple Ivanti queue items
|
||||
* into a single Jira ticket's summary, description, CVE, and vendor fields.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a consolidated summary for a multi-item Jira ticket.
|
||||
* Format: "[N findings] vendor - first_finding_title", truncated to 255 chars.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Generated summary, at most 255 characters
|
||||
*/
|
||||
export function generateConsolidatedSummary(items) {
|
||||
const count = items.length;
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
|
||||
const firstTitle = items[0]?.finding_title || 'Untitled';
|
||||
const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
|
||||
return raw.slice(0, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a structured description grouped by vendor for a consolidated Jira ticket.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Structured description with header and vendor-grouped items
|
||||
*/
|
||||
export function generateConsolidatedDescription(items) {
|
||||
const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
|
||||
|
||||
// Group by vendor
|
||||
const grouped = {};
|
||||
for (const item of items) {
|
||||
const vendor = item.vendor || 'Unknown Vendor';
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(item);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
for (const [vendor, vendorItems] of Object.entries(grouped)) {
|
||||
body += `== ${vendor} ==\n`;
|
||||
for (const item of vendorItems) {
|
||||
let cves = 'None';
|
||||
if (item.cves_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.cves_json);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
cves = parsed.join(', ');
|
||||
}
|
||||
} catch (e) {
|
||||
cves = 'None';
|
||||
}
|
||||
}
|
||||
body += `- ${item.finding_title}\n`;
|
||||
body += ` CVEs: ${cves}\n`;
|
||||
body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return header + body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first CVE from the first item that has a non-empty cves_json array.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} First CVE ID found, or empty string if none
|
||||
*/
|
||||
export function extractFirstCve(items) {
|
||||
for (const item of items) {
|
||||
if (item.cves_json) {
|
||||
try {
|
||||
const cves = JSON.parse(item.cves_json);
|
||||
if (Array.isArray(cves) && cves.length > 0) return cves[0];
|
||||
} catch (e) {
|
||||
// Skip items with invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the common vendor if all items share the same vendor.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Common vendor name if all items share it, empty string otherwise
|
||||
*/
|
||||
export function extractCommonVendor(items) {
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
return vendors.length === 1 ? vendors[0] : '';
|
||||
}
|
||||
175
frontend/src/utils/jiraConsolidation.test.js
Normal file
175
frontend/src/utils/jiraConsolidation.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
generateConsolidatedSummary,
|
||||
generateConsolidatedDescription,
|
||||
extractFirstCve,
|
||||
extractCommonVendor,
|
||||
} from './jiraConsolidation';
|
||||
|
||||
describe('generateConsolidatedSummary', () => {
|
||||
it('formats summary with count, common vendor, and first title', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
|
||||
{ vendor: 'Microsoft', finding_title: 'XSS in Outlook' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Microsoft - RCE in Exchange'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Multiple Vendors" when vendors differ', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
|
||||
{ vendor: 'Adobe', finding_title: 'Buffer overflow' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Multiple Vendors - RCE in Exchange'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Multiple Vendors" when vendor is null/empty', () => {
|
||||
const items = [
|
||||
{ vendor: null, finding_title: 'Finding A' },
|
||||
{ vendor: '', finding_title: 'Finding B' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Multiple Vendors - Finding A'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Untitled" when first item has no finding_title', () => {
|
||||
const items = [{ vendor: 'Cisco' }, { vendor: 'Cisco', finding_title: 'Bug' }];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Cisco - Untitled'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates to 255 characters', () => {
|
||||
const longTitle = 'A'.repeat(300);
|
||||
const items = [{ vendor: 'V', finding_title: longTitle }];
|
||||
const result = generateConsolidatedSummary(items);
|
||||
expect(result.length).toBeLessThanOrEqual(255);
|
||||
expect(result).toMatch(/^\[1 findings\] V - /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConsolidatedDescription', () => {
|
||||
it('includes header with item count', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug', cves_json: '["CVE-2024-001"]', hostname: 'host1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('Consolidated Jira ticket covering 1 Ivanti queue findings.');
|
||||
});
|
||||
|
||||
it('groups items by vendor', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug A', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
{ vendor: 'Adobe', finding_title: 'Bug B', hostname: 'h2', ip_address: '10.0.0.2' },
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug C', hostname: 'h3', ip_address: '10.0.0.3' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('== Microsoft ==');
|
||||
expect(result).toContain('== Adobe ==');
|
||||
expect(result).toContain('Bug A');
|
||||
expect(result).toContain('Bug B');
|
||||
expect(result).toContain('Bug C');
|
||||
});
|
||||
|
||||
it('uses "Unknown Vendor" for null/empty vendor', () => {
|
||||
const items = [
|
||||
{ vendor: null, finding_title: 'Bug', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('== Unknown Vendor ==');
|
||||
});
|
||||
|
||||
it('includes CVEs, hostname, and IP for each item', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco', finding_title: 'Vuln', cves_json: '["CVE-2024-100","CVE-2024-101"]', hostname: 'server1', ip_address: '192.168.1.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('CVEs: CVE-2024-100, CVE-2024-101');
|
||||
expect(result).toContain('Host: server1 (192.168.1.1)');
|
||||
});
|
||||
|
||||
it('shows "None" for items without CVEs', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco', finding_title: 'Vuln', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('CVEs: None');
|
||||
});
|
||||
|
||||
it('shows "N/A" for missing hostname and ip_address', () => {
|
||||
const items = [{ vendor: 'Cisco', finding_title: 'Vuln' }];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('Host: N/A (N/A)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstCve', () => {
|
||||
it('returns first CVE from first item with non-empty cves_json', () => {
|
||||
const items = [
|
||||
{ cves_json: null },
|
||||
{ cves_json: '["CVE-2024-200","CVE-2024-201"]' },
|
||||
{ cves_json: '["CVE-2024-300"]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('CVE-2024-200');
|
||||
});
|
||||
|
||||
it('returns empty string when no items have CVEs', () => {
|
||||
const items = [
|
||||
{ cves_json: null },
|
||||
{ cves_json: '[]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(extractFirstCve([])).toBe('');
|
||||
});
|
||||
|
||||
it('skips items with invalid JSON in cves_json', () => {
|
||||
const items = [
|
||||
{ cves_json: 'not-json' },
|
||||
{ cves_json: '["CVE-2024-500"]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('CVE-2024-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCommonVendor', () => {
|
||||
it('returns vendor when all items share the same vendor', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Microsoft' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('Microsoft');
|
||||
});
|
||||
|
||||
it('returns empty string when vendors differ', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Adobe' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when all vendors are null/empty', () => {
|
||||
const items = [
|
||||
{ vendor: null },
|
||||
{ vendor: '' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns vendor when some items have null vendor but all non-null are same', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco' },
|
||||
{ vendor: null },
|
||||
{ vendor: 'Cisco' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('Cisco');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user