Add Jira Data Center integration with UAT test script and use case docs
This commit is contained in:
809
backend/routes/jiraTickets.js
Normal file
809
backend/routes/jiraTickets.js
Normal file
@@ -0,0 +1,809 @@
|
||||
// routes/jiraTickets.js
|
||||
// Jira ticket CRUD + Jira REST API integration endpoints.
|
||||
// Extracted from server.js inline endpoints and extended with live Jira
|
||||
// operations (lookup, sync, create-in-jira, connection test).
|
||||
//
|
||||
// Charter Jira REST API compliance:
|
||||
// - All GETs include explicit field lists (no /rest/api/2/field)
|
||||
// - Sync uses bulk JQL search, not one-issue-at-a-time GETs
|
||||
// - No /rest/api/2/issue/bulk — updates are one at a time
|
||||
// - Inter-request delays enforced in jiraApi.js (1s GET, 2s write)
|
||||
// - Rate limits enforced client-side (1440/day, 60/min burst)
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
// Validation helpers
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
}
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createJiraTicketsRouter(db) {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jira API integration endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira/connection-test
|
||||
*
|
||||
* Verify Jira credentials and connectivity by testing the configured
|
||||
* Jira API connection. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { connected: true, user: { name, ... } }
|
||||
* @returns {object} 502 - { connected: false, status, error } | { connected: false, error }
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/connection-test', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.testConnection();
|
||||
if (result.ok) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_connection_test',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: { success: true, user: result.user.name },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
return res.json({ connected: true, user: result.user });
|
||||
}
|
||||
return res.status(502).json({ connected: false, status: result.status, error: result.body || result.error });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ connected: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/rate-limit
|
||||
*
|
||||
* Return current Jira API rate limit usage. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
||||
*/
|
||||
router.get('/rate-limit', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/lookup/:issueKey
|
||||
*
|
||||
* Fetch a single issue from Jira by its issue key (e.g., PROJECT-123).
|
||||
* Uses explicit `?fields=` parameter per Charter Jira REST API requirement.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @returns {object} 400 - { error } when issue key format is invalid
|
||||
* @returns {object} 404 - { error } when issue not found in Jira
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/lookup/:issueKey', requireAuth(db), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { issueKey } = req.params;
|
||||
if (!issueKey || !/^[A-Z][A-Z0-9_]+-\d+$/.test(issueKey)) {
|
||||
return res.status(400).json({ error: 'Invalid Jira issue key format. Expected PROJECT-123.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(issueKey);
|
||||
if (result.ok) {
|
||||
const issue = result.data;
|
||||
return res.json({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated,
|
||||
self: issue.self
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(result.status === 404 ? 404 : 502).json({
|
||||
error: result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.',
|
||||
details: result.body
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/search
|
||||
*
|
||||
* Search Jira issues using a JQL query. Results are capped at 1000 per page.
|
||||
* Charter compliance: JQL must include project+updated, assignee+updated,
|
||||
* or status+updated. Fields are always specified explicitly.
|
||||
*
|
||||
* @body {string} jql - JQL query string (required, max 2000 chars)
|
||||
* @body {number} [startAt] - Pagination offset
|
||||
* @body {number} [maxResults] - Page size (max 1000)
|
||||
* @body {string[]} [fields] - Explicit field list for the Jira response
|
||||
* @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] }
|
||||
* @returns {object} 400 - { error } when JQL is missing or too long
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira search failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/search', requireAuth(db), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { jql, startAt, maxResults, fields } = req.body;
|
||||
if (!jql || typeof jql !== 'string' || jql.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'JQL query is required.' });
|
||||
}
|
||||
if (jql.length > 2000) {
|
||||
return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.searchIssues(jql, {
|
||||
startAt,
|
||||
maxResults: Math.min(maxResults || 1000, 1000),
|
||||
fields: fields || undefined
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data;
|
||||
return res.json({
|
||||
total: data.total,
|
||||
startAt: data.startAt,
|
||||
maxResults: data.maxResults,
|
||||
issues: (data.issues || []).map(issue => ({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Jira search failed.', details: result.body });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/create-in-jira
|
||||
*
|
||||
* Create a new issue in Jira via the REST API and insert a linked local
|
||||
* record in the `jira_tickets` table. Requires Admin or Standard_User group.
|
||||
* Subject to 2s write delay enforced by jiraApi.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} summary - Issue summary (required, max 255 chars)
|
||||
* @body {string} [description] - Issue description
|
||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
||||
* @returns {object} 201 - { id, ticket_key, jira_url, message }
|
||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local save failed
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/create-in-jira', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
|
||||
const projectKey = project_key || jiraApi.JIRA_PROJECT_KEY;
|
||||
const issueType = issue_type || jiraApi.JIRA_ISSUE_TYPE;
|
||||
|
||||
if (!projectKey) {
|
||||
return res.status(400).json({ error: 'Project key is required. Set JIRA_PROJECT_KEY in .env or provide project_key in request.' });
|
||||
}
|
||||
|
||||
const fields = {
|
||||
project: { key: projectKey },
|
||||
summary: summary.trim(),
|
||||
issuetype: { name: issueType }
|
||||
};
|
||||
|
||||
if (description) {
|
||||
fields.description = description;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.createIssue(fields);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to create Jira issue.', details: result.body });
|
||||
}
|
||||
|
||||
const jiraIssue = result.data;
|
||||
const ticketKey = jiraIssue.key;
|
||||
const jiraUrl = jiraIssue.self
|
||||
? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error saving local Jira ticket record:', err);
|
||||
return res.status(207).json({
|
||||
warning: 'Issue created in Jira but local record failed to save.',
|
||||
jira_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/sync-all
|
||||
*
|
||||
* Bulk-sync all local tickets that have a Jira key by fetching their
|
||||
* latest status from Jira. Uses a single JQL bulk search per batch
|
||||
* instead of one GET per ticket (Charter-compliant). Stops early if
|
||||
* the rate limit budget is running low. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/sync-all', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
db.all(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''",
|
||||
[],
|
||||
async (err, tickets) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
// Batch keys into groups of 100 for JQL (avoid overly long queries)
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
// Check rate limit before each batch
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
}
|
||||
|
||||
const keys = batch.map(t => t.ticket_key);
|
||||
try {
|
||||
// Bulk JQL search — Charter-compliant, single request per batch
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
results.skipped += batch.length;
|
||||
results.errors.push('Jira rate limit hit during sync.');
|
||||
break;
|
||||
}
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search failed: HTTP ${result.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a map of key → Jira issue data
|
||||
const issueMap = {};
|
||||
for (const issue of (result.data.issues || [])) {
|
||||
issueMap[issue.key] = issue;
|
||||
}
|
||||
|
||||
// Update each local ticket from the search results
|
||||
for (const ticket of batch) {
|
||||
const issue = issueMap[ticket.ticket_key];
|
||||
if (!issue) {
|
||||
// Issue not returned — either not updated in last 24h or not found
|
||||
results.unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id],
|
||||
(updateErr) => updateErr ? reject(updateErr) : resolve()
|
||||
);
|
||||
});
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
results.failed++;
|
||||
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (searchErr) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search error: ${searchErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: results,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/:id/sync
|
||||
*
|
||||
* Sync a single local ticket with Jira by fetching the latest status,
|
||||
* summary, and mapping the Jira status to the local three-state model.
|
||||
* Uses getIssue with explicit fields (Charter-compliant GET).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @returns {object} 400 - { error } when ticket has no Jira key
|
||||
* @returns {object} 404 - { error } when local ticket not found
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
if (!ticket.ticket_key) {
|
||||
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(ticket.ticket_key);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
||||
}
|
||||
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, id],
|
||||
function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating synced ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Ticket synced with Jira',
|
||||
ticket_key: ticket.ticket_key,
|
||||
jira_status: jiraStatus,
|
||||
local_status: localStatus,
|
||||
summary: jiraSummary
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local CRUD endpoints (migrated from server.js)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira
|
||||
*
|
||||
* List all local JIRA ticket records with optional filters.
|
||||
* Results are ordered by `created_at` descending.
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by CVE ID
|
||||
* @query {string} [vendor] - Filter by vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @returns {object[]} 200 - Array of jira_tickets rows
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira
|
||||
*
|
||||
* Create a local JIRA ticket record (manual entry, no Jira API call).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} ticket_key - Jira issue key (required, max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
}
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jira/:id
|
||||
*
|
||||
* Update a local JIRA ticket record. Only provided fields are updated.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @body {string} [ticket_key] - Jira issue key (max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars, or null)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars, or null)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error } on validation failure or no fields provided
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jira/:id
|
||||
*
|
||||
* Delete a local JIRA ticket record. Admins bypass all restrictions.
|
||||
* Standard_User can only delete tickets they created, and cannot delete
|
||||
* tickets linked to active compliance items.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message }
|
||||
* @returns {object} 403 - { error } when ownership check fails or ticket is linked to compliance
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performJiraDelete();
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const ticketKey = ticket.ticket_key;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${ticketKey}%`],
|
||||
(compErr, compLinks) => {
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(ticketKey);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performJiraDelete();
|
||||
}
|
||||
);
|
||||
|
||||
function performJiraDelete() {
|
||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Jira workflow status name to the local three-state model.
|
||||
* Jira statuses vary by project workflow, so this uses broad categories.
|
||||
*/
|
||||
function mapJiraStatusToLocal(jiraStatus) {
|
||||
if (!jiraStatus) return 'Open';
|
||||
const lower = jiraStatus.toLowerCase();
|
||||
if (['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s))) {
|
||||
return 'Closed';
|
||||
}
|
||||
if (['in progress', 'in review', 'in development', 'in testing', 'review', 'testing', 'dev', 'active', 'implementing'].some(s => lower.includes(s))) {
|
||||
return 'In Progress';
|
||||
}
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
module.exports = createJiraTicketsRouter;
|
||||
Reference in New Issue
Block a user