Add Jira Data Center integration with UAT test script and use case docs

This commit is contained in:
root
2026-04-28 16:36:54 +00:00
parent 1186f9f807
commit b1069b1a05
7 changed files with 2577 additions and 0 deletions

View File

@@ -23,3 +23,21 @@ ATLAS_API_USER=
ATLAS_API_PASS=
# Set to true if behind Charter's SSL inspection proxy (disables TLS cert verification)
ATLAS_SKIP_TLS=false
# Jira Data Center REST API
# VPN or Charter Network connection required for all Jira instances.
# Service accounts use Basic Auth (JIRA_API_USER + JIRA_API_TOKEN).
# PATs require ATLSUP approval and naming convention: Function - Team - ATLSUP-XXXXX
# Rate limits: 1440 requests/day, burst of 60/minute.
JIRA_BASE_URL=
JIRA_AUTH_METHOD=basic
# Basic Auth — service account credentials
JIRA_API_USER=
JIRA_API_TOKEN=
# PAT Auth — set JIRA_AUTH_METHOD=pat to use
JIRA_PAT=
# Default project key and issue type for creating issues from the dashboard
JIRA_PROJECT_KEY=
JIRA_ISSUE_TYPE=Task
# Set to true if behind Charter's SSL inspection proxy
JIRA_SKIP_TLS=false

450
backend/helpers/jiraApi.js Normal file
View File

@@ -0,0 +1,450 @@
// Shared Jira Data Center REST API helpers
// Centralizes HTTP calls for Jira issue operations.
// Follows the same promise-based pattern as atlasApi.js and ivantiApi.js.
//
// =========================================================================
// Charter Jira REST API Compliance
// =========================================================================
// Authentication:
// - Service accounts use Basic Auth (required for shared integrations).
// - PATs require ATLSUP approval and naming convention:
// Function - Team - Approved ATLSUP ticket
// - SSO must NOT be used for REST API integrations.
//
// Rate limiting (Charter-posted):
// - 1 440 requests/day max
// - Burst cap of 60 requests/minute (accumulates 1 req/idle minute)
// - 429 response when limits are hit server-side
//
// Automation delays (Charter requirement):
// - 1 second delay between GET requests
// - 2 second delay between PUT, POST, or DELETE requests
//
// Forbidden patterns:
// - /rest/api/2/field — must specify fields explicitly in every call
// - /rest/api/2/issue/bulk — bulk updates are not allowed
// - Single-issue GET loops — use bulk JQL search instead
//
// Required patterns:
// - All GET requests MUST include a ?fields= parameter
// - JQL MUST include at least one of: project+updated, assignee+updated,
// status+updated
// - JQL should use &updated>=-Xh to only fetch changed issues
// - maxResults=1000 for search queries
// - Issues must be updated one at a time (no bulk PUT)
// =========================================================================
const https = require('https');
const http = require('http');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
const JIRA_AUTH_METHOD = (process.env.JIRA_AUTH_METHOD || 'basic').toLowerCase();
const JIRA_API_USER = process.env.JIRA_API_USER || '';
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
const JIRA_PAT = process.env.JIRA_PAT || '';
const JIRA_SKIP_TLS = process.env.JIRA_SKIP_TLS === 'true';
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY || '';
const JIRA_ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || 'Task';
const requiredVars = JIRA_AUTH_METHOD === 'pat'
? ['JIRA_BASE_URL', 'JIRA_PAT']
: ['JIRA_BASE_URL', 'JIRA_API_USER', 'JIRA_API_TOKEN'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[jira-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Jira API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Default fields — every GET must specify fields explicitly.
// /rest/api/2/field is forbidden; we define the field list here.
// ---------------------------------------------------------------------------
const DEFAULT_FIELDS = [
'summary', 'status', 'assignee', 'created', 'updated',
'priority', 'issuetype', 'project', 'resolution'
];
// ---------------------------------------------------------------------------
// Rate limiter — enforces Charter's posted limits
// 1 440 events/day, burst of 60 events/minute
// ---------------------------------------------------------------------------
const DAILY_LIMIT = 1440;
const BURST_LIMIT = 60;
const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
let dailyLog = [];
let minuteLog = [];
function pruneLog(log, windowMs) {
const cutoff = Date.now() - windowMs;
while (log.length > 0 && log[0] < cutoff) {
log.shift();
}
}
function checkRateLimit() {
pruneLog(dailyLog, DAY_MS);
pruneLog(minuteLog, MINUTE_MS);
if (dailyLog.length >= DAILY_LIMIT) {
return { allowed: false, reason: `Daily Jira API limit reached (${DAILY_LIMIT}/day). Resets at midnight.` };
}
if (minuteLog.length >= BURST_LIMIT) {
return { allowed: false, reason: `Burst Jira API limit reached (${BURST_LIMIT}/min). Wait and retry.` };
}
return { allowed: true };
}
function recordRequest() {
const now = Date.now();
dailyLog.push(now);
minuteLog.push(now);
}
/**
* Return current rate limit usage for diagnostics.
*/
function getRateLimitStatus() {
pruneLog(dailyLog, DAY_MS);
pruneLog(minuteLog, MINUTE_MS);
return {
daily: { used: dailyLog.length, limit: DAILY_LIMIT, remaining: DAILY_LIMIT - dailyLog.length },
burst: { used: minuteLog.length, limit: BURST_LIMIT, remaining: BURST_LIMIT - minuteLog.length }
};
}
// ---------------------------------------------------------------------------
// Inter-request delay — Charter automation requirements
// 1s between GETs, 2s between PUT/POST/DELETE
// ---------------------------------------------------------------------------
const GET_DELAY_MS = 1000;
const WRITE_DELAY_MS = 2000;
let lastRequestTime = 0;
let lastRequestMethod = '';
/**
* Wait the required delay before issuing the next request.
* GET → 1s, PUT/POST/DELETE → 2s since the previous request.
*/
function waitForDelay(method) {
const now = Date.now();
const requiredDelay = (lastRequestMethod === 'GET') ? GET_DELAY_MS
: (lastRequestMethod !== '') ? WRITE_DELAY_MS : 0;
const elapsed = now - lastRequestTime;
const remaining = requiredDelay - elapsed;
if (remaining > 0) {
return new Promise(resolve => setTimeout(resolve, remaining));
}
return Promise.resolve();
}
// ---------------------------------------------------------------------------
// Blocked endpoint guard
// ---------------------------------------------------------------------------
const BLOCKED_PATHS = [
'/rest/api/2/field', // Must specify fields in call, not query field list
'/rest/api/2/issue/bulk', // Bulk updates are not allowed
];
function isBlockedPath(urlPath) {
return BLOCKED_PATHS.some(blocked => urlPath.startsWith(blocked));
}
// ---------------------------------------------------------------------------
// Generic request — supports GET, POST, PUT, DELETE
// Enforces rate limits, inter-request delays, and blocked-path guards.
// ---------------------------------------------------------------------------
async function jiraRequest(method, urlPath, body, options) {
// Block forbidden endpoints
if (isBlockedPath(urlPath)) {
return Promise.reject(new Error(`Blocked: ${urlPath} is not allowed per Charter Jira API policy.`));
}
const limit = checkRateLimit();
if (!limit.allowed) {
return Promise.reject(new Error(limit.reason));
}
// Enforce inter-request delay
await waitForDelay(method);
const timeout = (options && options.timeout) || 15000;
const fullUrl = new URL(JIRA_BASE_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = {
'accept': 'application/json'
};
// Auth header
if (JIRA_AUTH_METHOD === 'pat') {
headers['authorization'] = 'Bearer ' + JIRA_PAT;
} else {
const authString = Buffer.from(JIRA_API_USER + ':' + JIRA_API_TOKEN).toString('base64');
headers['authorization'] = 'Basic ' + authString;
}
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
recordRequest();
lastRequestTime = Date.now();
lastRequestMethod = method;
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: method,
headers: headers,
timeout: timeout
};
if (isHttps) {
reqOptions.rejectUnauthorized = !JIRA_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 429) {
resolve({ status: 429, body: data, rateLimited: true });
} else {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
req.on('error', (err) => {
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
});
if (bodyStr) {
req.write(bodyStr);
}
req.end();
});
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function jiraGet(urlPath, options) {
return jiraRequest('GET', urlPath, null, options);
}
function jiraPost(urlPath, body, options) {
return jiraRequest('POST', urlPath, body, options);
}
function jiraPut(urlPath, body, options) {
return jiraRequest('PUT', urlPath, body, options);
}
function jiraDelete(urlPath, options) {
return jiraRequest('DELETE', urlPath, null, options);
}
// ---------------------------------------------------------------------------
// High-level Jira operations — all comply with Charter requirements
// ---------------------------------------------------------------------------
/**
* Fetch a single issue by key using a GET with explicit ?fields= parameter.
* Charter requires all GETs to specify fields — /rest/api/2/field is forbidden.
*
* NOTE: For syncing multiple tickets, prefer searchIssuesByKeys() which uses
* a single bulk JQL search instead of one GET per issue.
*
* @param {string} issueKey - e.g. "VULN-123"
* @param {string[]} [fields] - Jira field names to return
*/
async function getIssue(issueKey, fields) {
const fieldList = (fields || DEFAULT_FIELDS).join(',');
const res = await jiraGet(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`
);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Bulk-fetch issues by their keys using a single JQL search.
* This is the Charter-compliant way to sync multiple tickets — avoids
* querying one issue at a time.
*
* @param {string[]} issueKeys - Array of Jira issue keys
* @param {object} [opts] - { fields, maxResults }
*/
async function searchIssuesByKeys(issueKeys, opts) {
if (!issueKeys || issueKeys.length === 0) {
return { ok: true, data: { total: 0, issues: [] } };
}
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
// or similar, but key-based search is inherently scoped. We add updated
// clause for compliance.
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
const jql = `key in (${keyList}) AND updated >= -24h`;
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
return searchIssues(jql, { fields, maxResults, startAt: 0 });
}
/**
* Search issues via JQL (POST to /rest/api/2/search).
* Charter requirements enforced:
* - fields array is always specified (never omitted)
* - maxResults capped at 1000
*
* The caller is responsible for including an &updated clause in the JQL
* for recurring/scheduled queries.
*
* @param {string} jql - JQL query string
* @param {object} [opts] - { startAt, maxResults, fields }
*/
async function searchIssues(jql, opts) {
const startAt = (opts && opts.startAt) || 0;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const body = { jql, startAt, maxResults, fields };
const res = await jiraPost('/rest/api/2/search', body);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Create a new Jira issue (POST, subject to 2s delay).
* @param {object} fields - Jira issue fields object
*/
async function createIssue(fields) {
const res = await jiraPost('/rest/api/2/issue', { fields });
if (res.status === 201) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Update a single Jira issue (PUT, subject to 2s delay).
* Charter forbids bulk updates — issues must be updated one at a time.
* @param {string} issueKey
* @param {object} fields - Fields to update
*/
async function updateIssue(issueKey, fields) {
const res = await jiraPut(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
{ fields }
);
// Jira returns 204 on successful update
if (res.status === 204) {
return { ok: true };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Add a comment to an existing issue (POST, subject to 2s delay).
*/
async function addComment(issueKey, commentBody) {
const res = await jiraPost(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`,
{ body: commentBody }
);
if (res.status === 201) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Transition an issue to a new status (POST, subject to 2s delay).
* @param {string} issueKey
* @param {string} transitionId
*/
async function transitionIssue(issueKey, transitionId) {
const res = await jiraPost(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`,
{ transition: { id: transitionId } }
);
if (res.status === 204) {
return { ok: true };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Get available transitions for an issue.
* Uses GET with explicit fields parameter (transitions endpoint returns
* transitions by default, but we include the query param for compliance).
*/
async function getTransitions(issueKey) {
const res = await jiraGet(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`
);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Test connectivity — calls /rest/api/2/myself to verify credentials.
* This is a lightweight GET that returns the authenticated user.
*/
async function testConnection() {
try {
const res = await jiraGet('/rest/api/2/myself');
if (res.status === 200) {
const user = JSON.parse(res.body);
return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } };
}
return { ok: false, status: res.status, body: res.body };
} catch (err) {
return { ok: false, error: err.message };
}
}
module.exports = {
isConfigured,
jiraRequest,
jiraGet,
jiraPost,
jiraPut,
jiraDelete,
getIssue,
searchIssuesByKeys,
searchIssues,
createIssue,
updateIssue,
addComment,
transitionIssue,
getTransitions,
testConnection,
getRateLimitStatus,
DEFAULT_FIELDS,
JIRA_PROJECT_KEY,
JIRA_ISSUE_TYPE
};

View File

@@ -0,0 +1,63 @@
// Migration: Add Jira API sync columns to jira_tickets table
// Adds jira_id, jira_status, and last_synced_at columns to support
// live synchronization with Jira Data Center REST API.
// Idempotent — safe to run multiple times.
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting Jira sync columns migration...');
const newColumns = [
{ name: 'jira_id', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_id TEXT' },
{ name: 'jira_status', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_status TEXT' },
{ name: 'last_synced_at', sql: 'ALTER TABLE jira_tickets ADD COLUMN last_synced_at DATETIME' }
];
db.all('PRAGMA table_info(jira_tickets)', (err, columns) => {
if (err) {
console.error('Could not inspect jira_tickets:', err.message);
console.log('Run migrate_jira_tickets.js first to create the table.');
db.close();
return;
}
const existingNames = new Set(columns.map(c => c.name));
let pending = 0;
db.serialize(() => {
newColumns.forEach(({ name, sql }) => {
if (existingNames.has(name)) {
console.log(`✓ jira_tickets.${name} already exists — skipping`);
} else {
pending++;
db.run(sql, (runErr) => {
if (runErr) {
console.error(`✗ Failed to add ${name}:`, runErr.message);
} else {
console.log(`✓ Added jira_tickets.${name}`);
}
pending--;
if (pending === 0) finish();
});
}
});
// Create index on jira_id for lookups
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)', (idxErr) => {
if (idxErr) console.error('Index error:', idxErr.message);
else console.log('✓ jira_id index created');
});
if (pending === 0) finish();
});
});
function finish() {
db.close(() => {
console.log('Migration complete!');
});
}

View 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;

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
// ==========================================================================
// Jira UAT Test Script
// ==========================================================================
// Exercises every Jira REST API use case the STEAM Dashboard will run in
// production. Run this against the UAT instance before submitting the
// ATLSUP Rest API Approval ticket.
//
// Usage:
// cd backend
// node scripts/jira-uat-test.js
//
// Prerequisites:
// - backend/.env has JIRA_BASE_URL pointing to UAT
// - JIRA_API_USER / JIRA_API_TOKEN set to service account credentials
// - JIRA_PROJECT_KEY set to a UAT project your service account can access
// - Service account has been granted access to the target space by space owners
//
// The script logs every API call, response status, and timing to both
// console and a log file at backend/scripts/jira-uat-test.log for the
// ATLSUP reviewers.
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const jiraApi = require('../helpers/jiraApi');
const LOG_FILE = path.join(__dirname, 'jira-uat-test.log');
const results = [];
let createdIssueKey = null;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function log(level, message, data) {
const timestamp = new Date().toISOString();
const entry = { timestamp, level, message };
if (data !== undefined) entry.data = data;
results.push(entry);
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
console.log(line);
if (data) {
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
console.log(' ' + dataStr.split('\n').join('\n '));
}
}
function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); }
function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); }
function logInfo(message, data) { log('info', message, data); }
function logWarn(message, data) { log('warn', message, data); }
// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------
async function runTest(name, fn) {
logInfo(`--- Running: ${name} ---`);
const start = Date.now();
try {
await fn();
logPass(name, { durationMs: Date.now() - start });
return true;
} catch (err) {
logFail(name, { error: err.message, durationMs: Date.now() - start });
return false;
}
}
function assert(condition, message) {
if (!condition) throw new Error('Assertion failed: ' + message);
}
// ---------------------------------------------------------------------------
// Use Case 1: Connection Test (GET /rest/api/2/myself)
// Production use: Admin clicks "Test Connection" button on Jira settings panel
// ---------------------------------------------------------------------------
async function testConnection() {
const result = await jiraApi.testConnection();
assert(result.ok, 'Connection test should succeed. Got: ' + JSON.stringify(result));
assert(result.user && result.user.name, 'Should return authenticated user name');
logInfo('Authenticated as:', result.user);
}
// ---------------------------------------------------------------------------
// Use Case 2: Create Issue (POST /rest/api/2/issue)
// Production use: User clicks "Create in Jira" from CVE detail panel
// ---------------------------------------------------------------------------
async function testCreateIssue() {
const projectKey = jiraApi.JIRA_PROJECT_KEY;
assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env');
const fields = {
project: { key: projectKey },
summary: `[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ${new Date().toISOString()}`,
issuetype: { name: jiraApi.JIRA_ISSUE_TYPE || 'Task' },
description: 'Automated UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
};
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
const result = await jiraApi.createIssue(fields);
assert(result.ok, 'Create issue should succeed. Got: ' + JSON.stringify(result));
assert(result.data && result.data.key, 'Should return issue key');
createdIssueKey = result.data.key;
logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self });
}
// ---------------------------------------------------------------------------
// Use Case 3: Get Single Issue (GET /rest/api/2/issue/{key}?fields=...)
// Production use: User clicks "Sync" on a single Jira ticket row
// ---------------------------------------------------------------------------
async function testGetIssue() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.getIssue(createdIssueKey);
assert(result.ok, 'Get issue should succeed. Got: ' + JSON.stringify(result));
const issue = result.data;
assert(issue.key === createdIssueKey, 'Returned key should match');
assert(issue.fields && issue.fields.summary, 'Should have summary field');
assert(issue.fields.status, 'Should have status field');
logInfo('Fetched issue:', {
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status.name,
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null
});
}
// ---------------------------------------------------------------------------
// Use Case 4: Update Issue (PUT /rest/api/2/issue/{key})
// Production use: Local ticket edits synced back to Jira (future feature)
// ---------------------------------------------------------------------------
async function testUpdateIssue() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.updateIssue(createdIssueKey, {
summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}`
});
assert(result.ok, 'Update issue should succeed (204). Got: ' + JSON.stringify(result));
logInfo('Updated issue summary successfully');
}
// ---------------------------------------------------------------------------
// Use Case 5: Add Comment (POST /rest/api/2/issue/{key}/comment)
// Production use: Dashboard adds audit trail comments to linked Jira tickets
// ---------------------------------------------------------------------------
async function testAddComment() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const commentBody = `STEAM Dashboard UAT test comment.\nTimestamp: ${new Date().toISOString()}\nThis comment was created by the automated test script.`;
const result = await jiraApi.addComment(createdIssueKey, commentBody);
assert(result.ok, 'Add comment should succeed. Got: ' + JSON.stringify(result));
assert(result.data && result.data.id, 'Should return comment ID');
logInfo('Added comment:', { commentId: result.data.id });
}
// ---------------------------------------------------------------------------
// Use Case 6: Get Transitions (GET /rest/api/2/issue/{key}/transitions)
// Production use: Dashboard checks available workflow transitions before
// attempting to move a ticket to a new status
// ---------------------------------------------------------------------------
async function testGetTransitions() {
assert(createdIssueKey, 'Need a created issue key from previous test');
const result = await jiraApi.getTransitions(createdIssueKey);
assert(result.ok, 'Get transitions should succeed. Got: ' + JSON.stringify(result));
const transitions = result.data.transitions || [];
logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null })));
// Store for the transition test
return transitions;
}
// ---------------------------------------------------------------------------
// Use Case 7: Transition Issue (POST /rest/api/2/issue/{key}/transitions)
// Production use: Dashboard moves ticket status (e.g., Open → In Progress)
// ---------------------------------------------------------------------------
async function testTransitionIssue(transitions) {
assert(createdIssueKey, 'Need a created issue key from previous test');
if (!transitions || transitions.length === 0) {
logWarn('No transitions available — skipping transition test. This may be expected depending on the project workflow.');
return;
}
// Pick the first available transition
const transition = transitions[0];
logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`);
const result = await jiraApi.transitionIssue(createdIssueKey, transition.id);
assert(result.ok, 'Transition should succeed (204). Got: ' + JSON.stringify(result));
logInfo('Transition successful');
}
// ---------------------------------------------------------------------------
// Use Case 8: JQL Search (POST /rest/api/2/search)
// Production use: Bulk sync — fetches all tracked tickets in one request
// instead of one GET per ticket (Charter-compliant)
// ---------------------------------------------------------------------------
async function testJqlSearch() {
const projectKey = jiraApi.JIRA_PROJECT_KEY;
assert(projectKey, 'JIRA_PROJECT_KEY must be set');
const jql = `project = ${projectKey} AND updated >= -1h ORDER BY updated DESC`;
logInfo('Searching with JQL:', jql);
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
assert(result.ok, 'Search should succeed. Got: ' + JSON.stringify(result));
const data = result.data;
logInfo('Search results:', {
total: data.total,
returned: (data.issues || []).length,
issues: (data.issues || []).slice(0, 5).map(i => ({
key: i.key,
summary: i.fields.summary,
status: i.fields.status ? i.fields.status.name : null
}))
});
}
// ---------------------------------------------------------------------------
// Use Case 9: Bulk Key Search (searchIssuesByKeys)
// Production use: sync-all endpoint — fetches multiple tickets by key
// in a single JQL query
// ---------------------------------------------------------------------------
async function testBulkKeySearch() {
assert(createdIssueKey, 'Need a created issue key from previous test');
// Search for the issue we created plus a fake key to test partial results
const keys = [createdIssueKey, 'FAKE-99999'];
logInfo('Bulk searching keys:', keys);
const result = await jiraApi.searchIssuesByKeys(keys);
assert(result.ok, 'Bulk key search should succeed. Got: ' + JSON.stringify(result));
const found = (result.data.issues || []).map(i => i.key);
logInfo('Found issues:', found);
assert(found.includes(createdIssueKey), 'Should find the created issue');
}
// ---------------------------------------------------------------------------
// Use Case 10: Rate Limit Status Check
// Production use: Admin views rate limit usage on the Jira settings panel
// ---------------------------------------------------------------------------
async function testRateLimitStatus() {
const status = jiraApi.getRateLimitStatus();
assert(status.daily && typeof status.daily.used === 'number', 'Should have daily usage');
assert(status.burst && typeof status.burst.used === 'number', 'Should have burst usage');
logInfo('Rate limit status after all tests:', status);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
logInfo('=== STEAM Dashboard — Jira UAT Test Run ===');
logInfo('Timestamp: ' + new Date().toISOString());
logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)'));
logInfo('JIRA_AUTH_METHOD: ' + (process.env.JIRA_AUTH_METHOD || 'basic'));
logInfo('JIRA_API_USER: ' + (process.env.JIRA_API_USER || '(not set)'));
logInfo('JIRA_PROJECT_KEY: ' + (jiraApi.JIRA_PROJECT_KEY || '(not set)'));
logInfo('JIRA_ISSUE_TYPE: ' + (jiraApi.JIRA_ISSUE_TYPE || 'Task'));
logInfo('JIRA_SKIP_TLS: ' + (process.env.JIRA_SKIP_TLS || 'false'));
logInfo('isConfigured: ' + jiraApi.isConfigured);
logInfo('');
if (!jiraApi.isConfigured) {
logFail('Pre-flight check', 'Jira API is not configured. Set JIRA_BASE_URL, JIRA_API_USER, and JIRA_API_TOKEN in backend/.env');
writeLog();
process.exit(1);
}
let passed = 0;
let failed = 0;
let transitions = [];
// Run tests in order — later tests depend on the created issue
if (await runTest('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++;
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
if (await runTest('3. Get Single Issue (GET /issue/{key})', testGetIssue)) passed++; else failed++;
if (await runTest('4. Update Issue (PUT /issue/{key})', testUpdateIssue)) passed++; else failed++;
if (await runTest('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++;
if (await runTest('6. Get Transitions (GET /issue/{key}/transitions)', async () => {
transitions = await testGetTransitions();
})) passed++; else failed++;
if (await runTest('7. Transition Issue (POST /issue/{key}/transitions)', async () => {
await testTransitionIssue(transitions);
})) passed++; else failed++;
if (await runTest('8. JQL Search (POST /search)', testJqlSearch)) passed++; else failed++;
if (await runTest('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++;
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
logInfo('');
logInfo('=== Summary ===');
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
if (createdIssueKey) {
logInfo(`Test issue created: ${createdIssueKey} — delete manually after ATLSUP review if desired.`);
}
logInfo('Rate limit usage:', jiraApi.getRateLimitStatus());
writeLog();
if (failed > 0) {
console.log('\nSome tests failed. Review the log above and jira-uat-test.log for details.');
process.exit(1);
} else {
console.log('\nAll tests passed. Log saved to backend/scripts/jira-uat-test.log');
console.log('Next steps:');
console.log(' 1. Submit an ATLSUP Rest API Approval ticket');
console.log(' 2. Attach or reference jira-uat-test.log in the ticket');
console.log(' 3. Click "Script ran - Review Logs" on the ATLSUP ticket');
process.exit(0);
}
}
function writeLog() {
const lines = results.map(r => {
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
if (r.data) {
line += '\n ' + (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2)).split('\n').join('\n ');
}
return line;
});
fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8');
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});

169
docs/jira-api-use-cases.md Normal file
View File

@@ -0,0 +1,169 @@
# Jira REST API Use Cases — STEAM Security Dashboard
## Overview
The STEAM Security Dashboard is a self-hosted vulnerability management tool used by the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG teams. It integrates with Jira Data Center to create, track, and sync vulnerability remediation tickets linked to CVE records.
All API calls are made from a single Node.js backend process. The integration uses Basic Auth with a service account and enforces Charter's posted rate limits client-side.
---
## Charter Compliance Summary
| Requirement | Implementation |
|---|---|
| Authentication | Basic Auth with service account (`JIRA_API_USER` + `JIRA_API_TOKEN`) |
| Rate limit — daily | Client-side enforced: 1 440 requests/day max |
| Rate limit — burst | Client-side enforced: 60 requests/minute max |
| Inter-request delay — GETs | 1 second minimum between GET requests |
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
| Bulk reads via JQL | Multi-ticket sync uses a single `POST /rest/api/2/search` with JQL, not per-issue GETs |
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause |
| `maxResults` cap | Search queries capped at 1 000 results per page |
---
## Use Cases
### 1. Connection Test
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/myself` |
| **Trigger** | Admin clicks "Test Connection" on the Jira settings panel |
| **Frequency** | Manual, infrequent (a few times per day at most) |
| **Purpose** | Verify service account credentials and connectivity |
| **Fields requested** | Default (myself endpoint returns user profile) |
### 2. Create Issue
| | |
|---|---|
| **Endpoint** | `POST /rest/api/2/issue` |
| **Trigger** | User clicks "Create in Jira" from a CVE detail panel |
| **Frequency** | Manual, estimated 520 per day |
| **Purpose** | Create a vulnerability remediation ticket linked to a CVE/vendor pair |
| **Fields sent** | `project.key`, `summary`, `issuetype.name`, `description` |
| **Notes** | A local record is also created in the dashboard database linking the Jira key to the CVE |
### 3. Get Single Issue
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution` |
| **Trigger** | User clicks "Sync" on a single Jira ticket row |
| **Frequency** | Manual, estimated 1030 per day |
| **Purpose** | Refresh a single ticket's status and summary from Jira |
| **Notes** | Fields are always specified explicitly per Charter requirement |
### 4. Update Issue
| | |
|---|---|
| **Endpoint** | `PUT /rest/api/2/issue/{issueKey}` |
| **Trigger** | Future feature — local edits synced back to Jira |
| **Frequency** | Manual, estimated 510 per day when enabled |
| **Purpose** | Update issue summary or other fields from the dashboard |
| **Notes** | Issues are updated one at a time; bulk PUT is not used |
### 5. Add Comment
| | |
|---|---|
| **Endpoint** | `POST /rest/api/2/issue/{issueKey}/comment` |
| **Trigger** | Dashboard adds audit trail comments to linked tickets |
| **Frequency** | Automated on certain actions, estimated 515 per day |
| **Purpose** | Maintain an audit trail on the Jira ticket for compliance visibility |
### 6. Get Transitions
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}/transitions` |
| **Trigger** | Dashboard checks available workflow transitions before moving a ticket |
| **Frequency** | Manual, paired with transition calls, estimated 510 per day |
| **Purpose** | Discover valid status transitions for the issue's current workflow state |
### 7. Transition Issue
| | |
|---|---|
| **Endpoint** | `POST /rest/api/2/issue/{issueKey}/transitions` |
| **Trigger** | User moves a ticket to a new status from the dashboard |
| **Frequency** | Manual, estimated 510 per day |
| **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) |
### 8. JQL Search (Bulk Sync)
| | |
|---|---|
| **Endpoint** | `POST /rest/api/2/search` |
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
| **Frequency** | Manual, estimated 13 times per day |
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h` |
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
| **Notes** | Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
### 9. Issue Lookup
| | |
|---|---|
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=...` |
| **Trigger** | User looks up a Jira issue by key from the dashboard search |
| **Frequency** | Manual, estimated 515 per day |
| **Purpose** | Quick lookup of any Jira issue to view its current state |
---
## Estimated Daily API Usage
| Operation | Estimated calls/day | Method | Delay enforced |
|---|---|---|---|
| Connection test | 25 | GET | 1s |
| Create issue | 520 | POST | 2s |
| Get single issue | 1030 | GET | 1s |
| Update issue | 510 | PUT | 2s |
| Add comment | 515 | POST | 2s |
| Get transitions | 510 | GET | 1s |
| Transition issue | 510 | POST | 2s |
| JQL search (sync) | 15 | POST | 2s |
| Issue lookup | 515 | GET | 1s |
| **Total estimated** | **43120** | | |
Well within the 1 440/day limit. Burst usage stays under 60/minute due to enforced inter-request delays.
---
## Blocked Endpoints
The integration explicitly blocks these endpoints to comply with Charter policy:
- `/rest/api/2/field` — field metadata is never queried; fields are specified in code
- `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually
---
## Error Handling
- **429 responses**: Surfaced to the user as "Rate limit exceeded. Try again later." No automatic retry.
- **5xx responses**: Surfaced as "Jira API error" with the response body for debugging.
- **Network failures**: Caught and surfaced with the error message.
- **Timeout**: 15 second timeout per request; surfaced as a timeout error.
---
## UAT Test Evidence
The UAT test script (`backend/scripts/jira-uat-test.js`) exercises all use cases listed above and produces a log file at `backend/scripts/jira-uat-test.log`. This log can be attached to or referenced in the ATLSUP approval ticket.
To run:
```bash
cd backend
node scripts/jira-uat-test.js
```

View File

@@ -0,0 +1,725 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, RefreshCw, Plus, ExternalLink, Loader, AlertCircle, CheckCircle, Trash2, Edit3, X, Wifi, WifiOff, BarChart2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches DESIGN_SYSTEM.md 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',
},
statCard: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95))',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '10px',
padding: '1rem 1.25rem',
position: 'relative',
overflow: 'hidden',
},
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',
},
btnDanger: {
border: '1px solid rgba(239, 68, 68, 0.3)',
background: 'rgba(239, 68, 68, 0.1)',
color: '#FCA5A5',
},
btnSuccess: {
border: '1px solid rgba(16, 185, 129, 0.3)',
background: 'rgba(16, 185, 129, 0.1)',
color: '#6EE7B7',
},
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',
},
table: {
width: '100%',
borderCollapse: 'separate',
borderSpacing: '0 4px',
},
th: {
textAlign: 'left',
padding: '0.5rem 0.75rem',
fontSize: '0.7rem',
fontWeight: 700,
color: '#94A3B8',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid rgba(14, 165, 233, 0.1)',
},
td: {
padding: '0.6rem 0.75rem',
fontSize: '0.85rem',
color: '#E2E8F0',
borderBottom: '1px solid rgba(51, 65, 85, 0.3)',
},
badge: (color) => ({
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
padding: '0.2rem 0.6rem',
borderRadius: '9999px',
fontSize: '0.7rem',
fontWeight: 600,
border: `1px solid ${color}`,
background: color.replace(')', ', 0.15)').replace('rgb', 'rgba'),
color: color,
}),
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,
},
};
const STATUS_COLORS = {
'Open': '#F59E0B',
'In Progress': '#0EA5E9',
'Closed': '#10B981',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function JiraPage() {
const { canWrite, isAdmin } = useAuth();
// Data state
const [tickets, setTickets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Filters
const [filterStatus, setFilterStatus] = useState('');
const [filterSearch, setFilterSearch] = useState('');
// Connection test
const [connectionStatus, setConnectionStatus] = useState(null); // null | 'testing' | { connected, user?, error? }
// Rate limit
const [rateLimit, setRateLimit] = useState(null);
// Sync
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
// Lookup modal
const [showLookup, setShowLookup] = useState(false);
const [lookupKey, setLookupKey] = useState('');
const [lookupResult, setLookupResult] = useState(null);
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupError, setLookupError] = useState(null);
// Add/Edit modal
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' });
const [formError, setFormError] = useState(null);
const [formSaving, setFormSaving] = useState(false);
// Create-in-Jira modal
const [showCreateJira, setShowCreateJira] = useState(false);
const [createJiraForm, setCreateJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
const [createJiraError, setCreateJiraError] = useState(null);
const [createJiraSaving, setCreateJiraSaving] = useState(false);
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const fetchTickets = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch tickets');
const data = await res.json();
setTickets(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTickets(); }, [fetchTickets]);
// ---------------------------------------------------------------------------
// Connection test
// ---------------------------------------------------------------------------
const testConnection = async () => {
setConnectionStatus('testing');
try {
const res = await fetch(`${API_BASE}/jira-tickets/connection-test`, { credentials: 'include' });
const data = await res.json();
setConnectionStatus(data);
} catch (err) {
setConnectionStatus({ connected: false, error: err.message });
}
};
// ---------------------------------------------------------------------------
// Rate limit
// ---------------------------------------------------------------------------
const fetchRateLimit = async () => {
try {
const res = await fetch(`${API_BASE}/jira-tickets/rate-limit`, { credentials: 'include' });
if (res.ok) setRateLimit(await res.json());
} catch (_) { /* ignore */ }
};
useEffect(() => {
if (isAdmin()) fetchRateLimit();
}, [isAdmin]);
// ---------------------------------------------------------------------------
// Sync all
// ---------------------------------------------------------------------------
const syncAll = async () => {
setSyncing(true);
setSyncResult(null);
try {
const res = await fetch(`${API_BASE}/jira-tickets/sync-all`, { method: 'POST', credentials: 'include' });
const data = await res.json();
setSyncResult(data);
fetchTickets();
fetchRateLimit();
} catch (err) {
setSyncResult({ errors: [err.message] });
} finally {
setSyncing(false);
}
};
// ---------------------------------------------------------------------------
// Lookup
// ---------------------------------------------------------------------------
const doLookup = async () => {
if (!lookupKey.trim()) return;
setLookupLoading(true);
setLookupError(null);
setLookupResult(null);
try {
const res = await fetch(`${API_BASE}/jira-tickets/lookup/${encodeURIComponent(lookupKey.trim())}`, { credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
setLookupResult(await res.json());
} catch (err) {
setLookupError(err.message);
} finally {
setLookupLoading(false);
}
};
// ---------------------------------------------------------------------------
// CRUD — save (create or update)
// ---------------------------------------------------------------------------
const saveTicket = async () => {
setFormError(null);
setFormSaving(true);
try {
const method = editingId ? 'PUT' : 'POST';
const url = editingId ? `${API_BASE}/jira-tickets/${editingId}` : `${API_BASE}/jira-tickets`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
setShowForm(false);
setEditingId(null);
fetchTickets();
} catch (err) {
setFormError(err.message);
} finally {
setFormSaving(false);
}
};
const deleteTicket = async (id) => {
if (!window.confirm('Delete this Jira ticket record?')) return;
try {
const res = await fetch(`${API_BASE}/jira-tickets/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
fetchTickets();
} catch (err) {
alert(err.message);
}
};
const syncOne = async (id) => {
try {
const res = await fetch(`${API_BASE}/jira-tickets/${id}/sync`, { method: 'POST', credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `HTTP ${res.status}`);
}
fetchTickets();
fetchRateLimit();
} catch (err) {
alert(err.message);
}
};
// ---------------------------------------------------------------------------
// Create in Jira
// ---------------------------------------------------------------------------
const createInJira = async () => {
setCreateJiraError(null);
setCreateJiraSaving(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(createJiraForm),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
setShowCreateJira(false);
setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
fetchTickets();
fetchRateLimit();
} catch (err) {
setCreateJiraError(err.message);
} finally {
setCreateJiraSaving(false);
}
};
// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------
const filtered = tickets.filter(t => {
if (filterStatus && t.status !== filterStatus) return false;
if (filterSearch) {
const q = filterSearch.toLowerCase();
return (t.ticket_key || '').toLowerCase().includes(q)
|| (t.cve_id || '').toLowerCase().includes(q)
|| (t.vendor || '').toLowerCase().includes(q)
|| (t.summary || '').toLowerCase().includes(q);
}
return true;
});
const counts = {
total: tickets.length,
open: tickets.filter(t => t.status === 'Open').length,
inProgress: tickets.filter(t => t.status === 'In Progress').length,
closed: tickets.filter(t => t.status === 'Closed').length,
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div style={STYLES.page}>
{/* Page header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: '#F8FAFC', fontWeight: 700 }}>Jira Tickets</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', color: '#94A3B8' }}>
Track and sync Jira issues linked to CVE findings
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{isAdmin() && (
<button style={STYLES.btn} onClick={testConnection} disabled={connectionStatus === 'testing'}>
{connectionStatus === 'testing' ? <Loader size={14} className="animate-spin" /> : connectionStatus?.connected ? <Wifi size={14} /> : <WifiOff size={14} />}
Test Connection
</button>
)}
<button style={STYLES.btn} onClick={() => setShowLookup(true)}>
<Search size={14} /> Lookup Issue
</button>
{canWrite() && (
<>
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); }}>
<Plus size={14} /> Create in Jira
</button>
<button style={STYLES.btn} onClick={() => { setEditingId(null); setForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); setFormError(null); setShowForm(true); }}>
<Plus size={14} /> Add Manual
</button>
</>
)}
{isAdmin() && (
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess }} onClick={syncAll} disabled={syncing}>
{syncing ? <Loader size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Sync All
</button>
)}
</div>
</div>
{/* Connection status banner */}
{connectionStatus && connectionStatus !== 'testing' && (
<div style={{ ...STYLES.card, padding: '0.75rem 1rem', marginBottom: '1rem', borderColor: connectionStatus.connected ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem' }}>
{connectionStatus.connected
? <><CheckCircle size={16} color="#10B981" /><span style={{ color: '#6EE7B7' }}>Connected as {connectionStatus.user?.displayName || connectionStatus.user?.name}</span></>
: <><AlertCircle size={16} color="#EF4444" /><span style={{ color: '#FCA5A5' }}>Connection failed: {connectionStatus.error || `HTTP ${connectionStatus.status}`}</span></>
}
</div>
</div>
)}
{/* Sync result banner */}
{syncResult && (
<div style={{ ...STYLES.card, padding: '0.75rem 1rem', marginBottom: '1rem', borderColor: 'rgba(14, 165, 233, 0.3)' }}>
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>
Sync complete: {syncResult.synced} updated, {syncResult.unchanged || 0} unchanged, {syncResult.failed} failed, {syncResult.skipped} skipped
{syncResult.errors?.length > 0 && (
<div style={{ marginTop: '0.25rem', fontSize: '0.75rem', color: '#FCA5A5' }}>
{syncResult.errors.slice(0, 3).map((e, i) => <div key={i}>{e}</div>)}
</div>
)}
</div>
</div>
)}
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.5rem' }}>
{[
{ label: 'Total', value: counts.total, color: '#0EA5E9' },
{ label: 'Open', value: counts.open, color: '#F59E0B' },
{ label: 'In Progress', value: counts.inProgress, color: '#0EA5E9' },
{ label: 'Closed', value: counts.closed, color: '#10B981' },
].map(s => (
<div key={s.label} style={STYLES.statCard}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: s.color }} />
<div style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>{s.label}</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: s.color, fontFamily: 'monospace' }}>{s.value}</div>
</div>
))}
{rateLimit && (
<div style={STYLES.statCard}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: rateLimit.daily.remaining < 100 ? '#EF4444' : '#8B5CF6' }} />
<div style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>API Budget</div>
<div style={{ fontSize: '1rem', fontWeight: 700, color: '#C4B5FD', fontFamily: 'monospace' }}>
{rateLimit.daily.remaining}/{rateLimit.daily.limit}
</div>
<div style={{ fontSize: '0.65rem', color: '#94A3B8' }}>burst: {rateLimit.burst.remaining}/{rateLimit.burst.limit}</div>
</div>
)}
</div>
{/* Filters */}
<div style={{ ...STYLES.card, display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '1rem 1.25rem' }}>
<div style={{ flex: '1 1 250px' }}>
<input
style={STYLES.input}
placeholder="Search tickets, CVEs, vendors..."
value={filterSearch}
onChange={e => setFilterSearch(e.target.value)}
/>
</div>
<select
style={{ ...STYLES.input, width: 'auto', minWidth: '140px', cursor: 'pointer' }}
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
>
<option value="">All Statuses</option>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
{/* Table */}
{loading ? (
<div style={{ textAlign: 'center', padding: '3rem', color: '#94A3B8' }}>
<Loader size={24} className="animate-spin" style={{ margin: '0 auto 0.5rem' }} />
Loading tickets...
</div>
) : error ? (
<div style={{ textAlign: 'center', padding: '2rem', color: '#FCA5A5' }}>
<AlertCircle size={20} style={{ margin: '0 auto 0.5rem' }} />
{error}
</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem', color: '#94A3B8' }}>
{tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'}
</div>
) : (
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto' }}>
<table style={STYLES.table}>
<thead>
<tr>
<th style={STYLES.th}>Ticket</th>
<th style={STYLES.th}>CVE</th>
<th style={STYLES.th}>Vendor</th>
<th style={STYLES.th}>Summary</th>
<th style={STYLES.th}>Status</th>
<th style={STYLES.th}>Jira Status</th>
<th style={STYLES.th}>Last Synced</th>
<th style={STYLES.th}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
<tr key={t.id} style={{ transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={STYLES.td}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#7DD3FC' }}>{t.ticket_key}</span>
{t.url && (
<a href={t.url} target="_blank" rel="noopener noreferrer" style={{ color: '#94A3B8' }} title="Open in Jira">
<ExternalLink size={12} />
</a>
)}
</div>
</td>
<td style={{ ...STYLES.td, fontFamily: 'monospace', fontSize: '0.8rem' }}>{t.cve_id}</td>
<td style={STYLES.td}>{t.vendor}</td>
<td style={{ ...STYLES.td, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
<td style={STYLES.td}>
<span style={STYLES.badge(STATUS_COLORS[t.status] || '#94A3B8')}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLORS[t.status] || '#94A3B8' }} />
{t.status}
</span>
</td>
<td style={{ ...STYLES.td, fontSize: '0.8rem', color: '#CBD5E1' }}>{t.jira_status || '-'}</td>
<td style={{ ...STYLES.td, fontSize: '0.75rem', color: '#94A3B8' }}>
{t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
</td>
<td style={STYLES.td}>
<div style={{ display: 'flex', gap: '0.3rem' }}>
{canWrite() && t.ticket_key && (
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => syncOne(t.id)} title="Sync with Jira">
<RefreshCw size={12} />
</button>
)}
{canWrite() && (
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
setEditingId(t.id);
setForm({ cve_id: t.cve_id, vendor: t.vendor, ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
setFormError(null);
setShowForm(true);
}} title="Edit">
<Edit3 size={12} />
</button>
)}
{canWrite() && (
<button style={{ ...STYLES.btn, ...STYLES.btnDanger, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => deleteTicket(t.id)} title="Delete">
<Trash2 size={12} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Lookup Modal */}
{showLookup && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowLookup(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' }}>Lookup Jira Issue</h3>
<button onClick={() => setShowLookup(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
style={{ ...STYLES.input, flex: 1 }}
placeholder="e.g. VULN-123"
value={lookupKey}
onChange={e => setLookupKey(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && doLookup()}
/>
<button style={STYLES.btn} onClick={doLookup} disabled={lookupLoading}>
{lookupLoading ? <Loader size={14} className="animate-spin" /> : <Search size={14} />}
Lookup
</button>
</div>
{lookupError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{lookupError}</div>}
{lookupResult && (
<div style={{ background: 'rgba(15, 23, 42, 0.6)', borderRadius: '8px', padding: '1rem', fontSize: '0.85rem', color: '#E2E8F0' }}>
<div style={{ fontWeight: 700, color: '#7DD3FC', marginBottom: '0.5rem' }}>{lookupResult.key}</div>
<div><strong>Summary:</strong> {lookupResult.summary}</div>
<div><strong>Status:</strong> {lookupResult.status}</div>
<div><strong>Type:</strong> {lookupResult.issuetype}</div>
<div><strong>Priority:</strong> {lookupResult.priority}</div>
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
</div>
)}
</div>
</div>
)}
{/* Add/Edit Modal */}
{showForm && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowForm(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' }}>{editingId ? 'Edit Ticket' : 'Add Jira Ticket'}</h3>
<button onClick={() => setShowForm(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
</div>
{formError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{formError}</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</label>
<input style={STYLES.input} placeholder="CVE-2024-1234" value={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
<input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Ticket Key</label>
<input style={STYLES.input} placeholder="PROJECT-123" value={form.ticket_key} onChange={e => setForm(f => ({ ...f, ticket_key: e.target.value.toUpperCase() }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>URL</label>
<input style={STYLES.input} placeholder="https://jira.example.com/browse/..." value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary</label>
<input style={STYLES.input} placeholder="Brief description" value={form.summary} onChange={e => setForm(f => ({ ...f, summary: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Status</label>
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={saveTicket} disabled={formSaving}>
{formSaving ? <Loader size={14} className="animate-spin" /> : <CheckCircle size={14} />}
{editingId ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
{/* Create in Jira Modal */}
{showCreateJira && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowCreateJira(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 Issue in Jira</h3>
<button onClick={() => setShowCreateJira(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Creates a new issue in Jira via the REST API and links it to a CVE locally.
</p>
{createJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{createJiraError}</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</label>
<input style={STYLES.input} placeholder="CVE-2024-1234" value={createJiraForm.cve_id} onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
<input style={STYLES.input} placeholder="Vendor name" value={createJiraForm.vendor} onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary</label>
<input style={STYLES.input} placeholder="Issue summary (max 255 chars)" value={createJiraForm.summary} onChange={e => setCreateJiraForm(f => ({ ...f, summary: e.target.value }))} maxLength={255} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
<textarea style={{ ...STYLES.input, minHeight: '80px', resize: 'vertical' }} placeholder="Detailed description..." value={createJiraForm.description} onChange={e => setCreateJiraForm(f => ({ ...f, description: e.target.value }))} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
<input style={STYLES.input} placeholder="Task" value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))} />
</div>
</div>
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>
{createJiraSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
Create in Jira
</button>
</div>
</div>
</div>
)}
</div>
);
}