From b1069b1a05133d49c0cd5430118e0b9f98c64445 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Apr 2026 16:36:54 +0000 Subject: [PATCH] Add Jira Data Center integration with UAT test script and use case docs --- backend/.env.example | 18 + backend/helpers/jiraApi.js | 450 +++++++++++ backend/migrations/add_jira_sync_columns.js | 63 ++ backend/routes/jiraTickets.js | 809 ++++++++++++++++++++ backend/scripts/jira-uat-test.js | 343 +++++++++ docs/jira-api-use-cases.md | 169 ++++ frontend/src/components/pages/JiraPage.js | 725 ++++++++++++++++++ 7 files changed, 2577 insertions(+) create mode 100644 backend/helpers/jiraApi.js create mode 100644 backend/migrations/add_jira_sync_columns.js create mode 100644 backend/routes/jiraTickets.js create mode 100644 backend/scripts/jira-uat-test.js create mode 100644 docs/jira-api-use-cases.md create mode 100644 frontend/src/components/pages/JiraPage.js diff --git a/backend/.env.example b/backend/.env.example index 018f0c2..cf81064 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/helpers/jiraApi.js b/backend/helpers/jiraApi.js new file mode 100644 index 0000000..9ec878f --- /dev/null +++ b/backend/helpers/jiraApi.js @@ -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 +}; diff --git a/backend/migrations/add_jira_sync_columns.js b/backend/migrations/add_jira_sync_columns.js new file mode 100644 index 0000000..ad40a0e --- /dev/null +++ b/backend/migrations/add_jira_sync_columns.js @@ -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!'); + }); +} diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js new file mode 100644 index 0000000..cf29375 --- /dev/null +++ b/backend/routes/jiraTickets.js @@ -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; diff --git a/backend/scripts/jira-uat-test.js b/backend/scripts/jira-uat-test.js new file mode 100644 index 0000000..0bf3fe3 --- /dev/null +++ b/backend/scripts/jira-uat-test.js @@ -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); +}); diff --git a/docs/jira-api-use-cases.md b/docs/jira-api-use-cases.md new file mode 100644 index 0000000..9be6d57 --- /dev/null +++ b/docs/jira-api-use-cases.md @@ -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 5–20 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 10–30 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 5–10 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 5–15 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 5–10 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 5–10 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 1–3 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 5–15 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 | 2–5 | GET | 1s | +| Create issue | 5–20 | POST | 2s | +| Get single issue | 10–30 | GET | 1s | +| Update issue | 5–10 | PUT | 2s | +| Add comment | 5–15 | POST | 2s | +| Get transitions | 5–10 | GET | 1s | +| Transition issue | 5–10 | POST | 2s | +| JQL search (sync) | 1–5 | POST | 2s | +| Issue lookup | 5–15 | GET | 1s | +| **Total estimated** | **43–120** | | | + +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 +``` + diff --git a/frontend/src/components/pages/JiraPage.js b/frontend/src/components/pages/JiraPage.js new file mode 100644 index 0000000..fef1b5e --- /dev/null +++ b/frontend/src/components/pages/JiraPage.js @@ -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 ( +
+ {/* Page header */} +
+
+

Jira Tickets

+

+ Track and sync Jira issues linked to CVE findings +

+
+
+ {isAdmin() && ( + + )} + + {canWrite() && ( + <> + + + + )} + {isAdmin() && ( + + )} +
+
+ + {/* Connection status banner */} + {connectionStatus && connectionStatus !== 'testing' && ( +
+
+ {connectionStatus.connected + ? <>Connected as {connectionStatus.user?.displayName || connectionStatus.user?.name} + : <>Connection failed: {connectionStatus.error || `HTTP ${connectionStatus.status}`} + } +
+
+ )} + + {/* Sync result banner */} + {syncResult && ( +
+
+ Sync complete: {syncResult.synced} updated, {syncResult.unchanged || 0} unchanged, {syncResult.failed} failed, {syncResult.skipped} skipped + {syncResult.errors?.length > 0 && ( +
+ {syncResult.errors.slice(0, 3).map((e, i) =>
{e}
)} +
+ )} +
+
+ )} + + {/* Stats row */} +
+ {[ + { 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 => ( +
+
+
{s.label}
+
{s.value}
+
+ ))} + {rateLimit && ( +
+
+
API Budget
+
+ {rateLimit.daily.remaining}/{rateLimit.daily.limit} +
+
burst: {rateLimit.burst.remaining}/{rateLimit.burst.limit}
+
+ )} +
+ + {/* Filters */} +
+
+ setFilterSearch(e.target.value)} + /> +
+ +
+ + {/* Table */} + {loading ? ( +
+ + Loading tickets... +
+ ) : error ? ( +
+ + {error} +
+ ) : filtered.length === 0 ? ( +
+ {tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'} +
+ ) : ( +
+ + + + + + + + + + + + + + + {filtered.map(t => ( + e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > + + + + + + + + + + ))} + +
TicketCVEVendorSummaryStatusJira StatusLast SyncedActions
+
+ {t.ticket_key} + {t.url && ( + + + + )} +
+
{t.cve_id}{t.vendor}{t.summary || '-'} + + + {t.status} + + {t.jira_status || '-'} + {t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'} + +
+ {canWrite() && t.ticket_key && ( + + )} + {canWrite() && ( + + )} + {canWrite() && ( + + )} +
+
+
+ )} + + {/* Lookup Modal */} + {showLookup && ( +
+
setShowLookup(false)} /> +
+
+

Lookup Jira Issue

+ +
+
+ setLookupKey(e.target.value.toUpperCase())} + onKeyDown={e => e.key === 'Enter' && doLookup()} + /> + +
+ {lookupError &&
{lookupError}
} + {lookupResult && ( +
+
{lookupResult.key}
+
Summary: {lookupResult.summary}
+
Status: {lookupResult.status}
+
Type: {lookupResult.issuetype}
+
Priority: {lookupResult.priority}
+
Assignee: {lookupResult.assignee || 'Unassigned'}
+
Updated: {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}
+
+ )} +
+
+ )} + + {/* Add/Edit Modal */} + {showForm && ( +
+
setShowForm(false)} /> +
+
+

{editingId ? 'Edit Ticket' : 'Add Jira Ticket'}

+ +
+ {formError &&
{formError}
} +
+
+ + setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} /> +
+
+ + setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} /> +
+
+ + setForm(f => ({ ...f, ticket_key: e.target.value.toUpperCase() }))} /> +
+
+ + setForm(f => ({ ...f, url: e.target.value }))} /> +
+
+ + setForm(f => ({ ...f, summary: e.target.value }))} /> +
+
+ + +
+ +
+
+
+ )} + + {/* Create in Jira Modal */} + {showCreateJira && ( +
+
setShowCreateJira(false)} /> +
+
+

Create Issue in Jira

+ +
+

+ Creates a new issue in Jira via the REST API and links it to a CVE locally. +

+ {createJiraError &&
{createJiraError}
} +
+
+ + setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} /> +
+
+ + setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} /> +
+
+ + setCreateJiraForm(f => ({ ...f, summary: e.target.value }))} maxLength={255} /> +
+
+ +