chore: reorganize docs, document migrations, gitignore operational files for v1.0.0 release

This commit is contained in:
Jordan Ramos
2026-05-01 20:53:39 +00:00
parent c8b3626ac5
commit 034d3963b9
39 changed files with 792 additions and 917 deletions

View File

@@ -1,486 +0,0 @@
#!/usr/bin/env node
// ==========================================================================
// CARD API UAT Test Script
// ==========================================================================
// Exercises every CARD REST API use case the STEAM Dashboard will run in
// production. Run this against the UAT instance to verify the service
// account has been onboarded and all endpoints are accessible.
//
// Usage:
// cd backend
// node scripts/card-uat-test.js # auto-discovers NTS-AEO-STEAM
// node scripts/card-uat-test.js NTS-ACCESS-ENG # target a specific team
//
// Prerequisites:
// - backend/.env has CARD_API_URL pointing to UAT
// (https://card.caas.stage.charterlab.com)
// - CARD_API_USER / CARD_API_PASS set to service account credentials
// - CARD_SKIP_TLS=true if behind Charter's SSL inspection proxy
// - Service account has been onboarded with the CARD team
//
// The script logs every API call, response status, and timing to both
// console and a log file at backend/scripts/card-uat-test.log.
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const cardApi = require('../helpers/cardApi');
const LOG_FILE = path.join(__dirname, 'card-uat-test.log');
const results = [];
// CLI: optional team name override (e.g. node scripts/card-uat-test.js NTS-ACCESS-ENG)
const CLI_TEAM = process.argv[2] || null;
// State carried between tests
let discoveredTeam = null;
let discoveredAssetId = null;
let discoveredUpdateToken = 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);
const truncated = dataStr.length > 2000
? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]'
: dataStr;
console.log(' ' + truncated.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: Token Acquisition (GET /api/v1/auth/get_token)
// Production use: Automatic — every CARD API call acquires/reuses a token
// ---------------------------------------------------------------------------
async function testTokenAcquisition() {
const result = await cardApi.testConnection();
assert(result.ok, 'Token acquisition should succeed. Got: ' + JSON.stringify(result));
logInfo('Token acquired (truncated):', result.token);
}
// ---------------------------------------------------------------------------
// Use Case 2: List Teams (GET /api/v1/teams)
// Production use: Populate team dropdowns in Confirm/Decline/Redirect forms
// ---------------------------------------------------------------------------
async function testListTeams() {
const result = await cardApi.getTeams();
assert(result.ok, 'List teams should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let teams;
try {
teams = JSON.parse(result.body);
} catch (_) {
teams = result.body;
}
const teamList = Array.isArray(teams) ? teams : (teams && teams.teams) || [];
logInfo('Teams returned:', { count: teamList.length, sample: teamList.slice(0, 10) });
// Extract team name — CARD API uses card_team_name or _id
function extractTeamName(t) {
if (typeof t === 'string') return t;
return t.card_team_name || t._id || t.name || t.teamName || '';
}
// If CLI specified a team, use it directly; otherwise auto-discover
if (CLI_TEAM && teamList.length > 0) {
const cliUpper = CLI_TEAM.toUpperCase();
const match = teamList.find(t => extractTeamName(t).toUpperCase() === cliUpper);
if (match) {
discoveredTeam = extractTeamName(match);
logInfo('Using CLI-specified team:', discoveredTeam);
} else {
// Fuzzy: check if any team contains the CLI string
const fuzzy = teamList.find(t => extractTeamName(t).toUpperCase().includes(cliUpper));
if (fuzzy) {
discoveredTeam = extractTeamName(fuzzy);
logInfo('CLI team "' + CLI_TEAM + '" not exact — fuzzy matched:', discoveredTeam);
} else {
logWarn('CLI team "' + CLI_TEAM + '" not found in ' + teamList.length + ' teams. Falling back to auto-discover.');
}
}
}
// Auto-discover if CLI didn't resolve
if (!discoveredTeam && teamList.length > 0) {
const steamTeam = teamList.find(t => {
const name = extractTeamName(t);
return name.includes('NTS-AEO-STEAM') || name.includes('STEAM');
});
discoveredTeam = steamTeam
? extractTeamName(steamTeam)
: extractTeamName(teamList[0]);
logInfo('Using team for subsequent tests:', discoveredTeam);
}
assert(teamList.length > 0, 'Should return at least one team');
}
// ---------------------------------------------------------------------------
// Use Case 3: List Team Assets (GET /api/v1/team/{teamName}/assets)
// Production use: Asset search UI — find Granite IDs for reassigned assets
// NOTE: CARD API requires a disposition filter — unfiltered calls return 500.
// ---------------------------------------------------------------------------
async function testListTeamAssets() {
assert(discoveredTeam, 'Need a team from previous test');
// CARD API requires disposition — use 'confirmed' as the default
const result = await cardApi.getTeamAssets(discoveredTeam, { disposition: 'confirmed', pageSize: 10 });
assert(result.ok, 'List team assets should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let data;
try {
data = JSON.parse(result.body);
} catch (_) {
data = result.body;
}
const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || [];
const total = data && data.total !== undefined ? data.total : assets.length;
logInfo('Team assets (confirmed):', { team: discoveredTeam, total, returned: assets.length, sample: assets.slice(0, 3) });
// Grab first asset ID for owner lookup test
if (assets.length > 0) {
const first = assets[0];
discoveredAssetId = first.asset_id || first.assetId || first.id || first.ipn || first._id || null;
if (typeof first === 'string') discoveredAssetId = first;
logInfo('Using asset for subsequent tests:', discoveredAssetId);
}
}
// ---------------------------------------------------------------------------
// Use Case 4: List Team Assets with Disposition Filter
// Production use: Filter assets by confirmed/unconfirmed/declined/candidate
// ---------------------------------------------------------------------------
async function testListTeamAssetsFiltered() {
assert(discoveredTeam, 'Need a team from previous test');
const dispositions = ['confirmed', 'unconfirmed', 'declined', 'candidate'];
for (const disposition of dispositions) {
const result = await cardApi.getTeamAssets(discoveredTeam, { disposition, pageSize: 5 });
let count = '?';
try {
const data = JSON.parse(result.body);
const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || [];
count = data && data.total !== undefined ? data.total : assets.length;
} catch (_) { /* ignore parse errors */ }
logInfo(` ${disposition}: HTTP ${result.status}, count=${count}`);
// We don't assert success here — some dispositions may return 0 results
// but the endpoint should still respond with 200
assert(
result.status >= 200 && result.status < 500,
`${disposition} filter should not return server error. Got HTTP ${result.status}`
);
}
}
// ---------------------------------------------------------------------------
// Use Case 5: Get Owner Record (GET /api/v1/owner/{assetId})
// Production use: Retrieve update_token before confirm/decline/redirect
// ---------------------------------------------------------------------------
async function testGetOwner() {
assert(discoveredAssetId, 'Need an asset ID from previous test');
const result = await cardApi.getOwner(discoveredAssetId);
assert(result.ok, 'Get owner should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
let ownerData;
try {
ownerData = JSON.parse(result.body);
} catch (_) {
ownerData = result.body;
}
logInfo('Owner record:', ownerData);
// Extract update_token — CARD nests it inside owner object
const updateToken = (ownerData && ownerData.owner && ownerData.owner.update_token)
|| (ownerData && ownerData.update_token)
|| null;
if (updateToken) {
discoveredUpdateToken = updateToken;
logInfo('update_token acquired:', discoveredUpdateToken);
} else {
logWarn('No update_token in owner response — mutation tests will be skipped');
}
}
// ---------------------------------------------------------------------------
// Use Case 6: Token Reuse (verify caching works)
// Production use: Consecutive API calls should reuse the cached token
// ---------------------------------------------------------------------------
async function testTokenReuse() {
// Make two rapid calls — second should reuse the cached token
const start1 = Date.now();
const r1 = await cardApi.getTeams();
const dur1 = Date.now() - start1;
const start2 = Date.now();
const r2 = await cardApi.getTeams();
const dur2 = Date.now() - start2;
assert(r1.ok, 'First call should succeed');
assert(r2.ok, 'Second call should succeed');
logInfo('Token reuse timing:', { firstCallMs: dur1, secondCallMs: dur2 });
// Second call should generally be faster (no token acquisition), but we
// don't assert timing — just log it for review
}
// ---------------------------------------------------------------------------
// Use Case 7: Confirm Asset (POST /api/v2/owner/{assetId}/confirm)
// Production use: User clicks "Confirm" on a CARD queue item
// NOTE: This is a MUTATION — only runs if we have a valid update_token
// and the asset is in a confirmable state. May fail in UAT if the
// asset state doesn't allow confirmation. That's expected.
// ---------------------------------------------------------------------------
async function testConfirmAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping confirm test — no update_token available');
return;
}
// Re-fetch update_token to ensure it's current
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for confirm');
const result = await cardApi.confirmAsset(
discoveredAssetId,
discoveredTeam,
token,
'STEAM Dashboard UAT test — confirm'
);
logInfo('Confirm result:', { status: result.status, body: (result.body || '').substring(0, 500) });
// Accept 200-299 as success, but also accept 400/409 (asset may already
// be confirmed or in a state that doesn't allow confirmation in UAT)
if (result.ok) {
logInfo('Confirm succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Confirm returned ' + result.status + ' — asset may already be in confirmed state (expected in UAT)');
} else {
assert(false, 'Confirm returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Use Case 8: Decline Asset (POST /api/v2/owner/{assetId}/decline)
// Production use: User clicks "Decline" on a CARD queue item
// ---------------------------------------------------------------------------
async function testDeclineAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping decline test — no update_token available');
return;
}
// Re-fetch update_token
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for decline');
const result = await cardApi.declineAsset(
discoveredAssetId,
discoveredTeam,
token,
'STEAM Dashboard UAT test — decline'
);
logInfo('Decline result:', { status: result.status, body: (result.body || '').substring(0, 500) });
if (result.ok) {
logInfo('Decline succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Decline returned ' + result.status + ' — asset may not be in a declinable state (expected in UAT)');
} else {
assert(false, 'Decline returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Use Case 9: Redirect Asset (POST /api/v2/owner/{assetId}/{from}/redirect)
// Production use: User clicks "Redirect" on a CARD queue item
// NOTE: Requires two different teams. We'll attempt it but expect it may
// fail in UAT if only one team is available.
// ---------------------------------------------------------------------------
async function testRedirectAsset() {
assert(discoveredAssetId, 'Need an asset ID');
assert(discoveredTeam, 'Need a team name');
if (!discoveredUpdateToken) {
logWarn('Skipping redirect test — no update_token available');
return;
}
// We need a second team for redirect. Try to find one from the teams list.
const teamsRes = await cardApi.getTeams();
let teams = [];
try {
const parsed = JSON.parse(teamsRes.body);
teams = Array.isArray(parsed) ? parsed : (parsed.teams || []);
} catch (_) { /* ignore */ }
const teamNames = teams.map(t => typeof t === 'string' ? t : (t.card_team_name || t._id || t.name || t.teamName || ''));
const otherTeam = teamNames.find(t => t && t !== discoveredTeam);
if (!otherTeam) {
logWarn('Only one team available — cannot test redirect (requires from and to teams)');
return;
}
logInfo('Redirect test:', { from: discoveredTeam, to: otherTeam });
// Re-fetch update_token
const ownerRes = await cardApi.getOwner(discoveredAssetId);
assert(ownerRes.ok, 'Owner re-fetch should succeed');
const ownerData = JSON.parse(ownerRes.body);
const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token;
assert(token, 'Should have update_token for redirect');
const result = await cardApi.redirectAsset(
discoveredAssetId,
discoveredTeam,
otherTeam,
token
);
logInfo('Redirect result:', { status: result.status, body: (result.body || '').substring(0, 500) });
if (result.ok) {
logInfo('Redirect succeeded');
} else if (result.status === 400 || result.status === 409 || result.status === 422) {
logWarn('Redirect returned ' + result.status + ' — asset may not be in a redirectable state (expected in UAT)');
} else {
assert(false, 'Redirect returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
logInfo('=== STEAM Dashboard — CARD API UAT Test Run ===');
logInfo('Timestamp: ' + new Date().toISOString());
logInfo('CARD_API_URL: ' + (process.env.CARD_API_URL || '(not set)'));
logInfo('CARD_API_USER: ' + (process.env.CARD_API_USER || '(not set)'));
logInfo('CARD_SKIP_TLS: ' + (process.env.CARD_SKIP_TLS || 'false'));
logInfo('isConfigured: ' + cardApi.isConfigured);
logInfo('');
if (!cardApi.isConfigured) {
logFail('Pre-flight check', {
error: 'CARD API is not configured. Set CARD_API_URL, CARD_API_USER, and CARD_API_PASS in backend/.env',
missing: cardApi.missingVars,
});
writeLog();
process.exit(1);
}
let passed = 0;
let failed = 0;
// Read-only tests first (safe to run in any environment)
if (await runTest('1. Token Acquisition (GET /auth/get_token)', testTokenAcquisition)) passed++; else failed++;
if (await runTest('2. List Teams (GET /teams)', testListTeams)) passed++; else failed++;
if (await runTest('3. List Team Assets (GET /team/{name}/assets)', testListTeamAssets)) passed++; else failed++;
if (await runTest('4. List Team Assets — Disposition Filters', testListTeamAssetsFiltered)) passed++; else failed++;
if (await runTest('5. Get Owner Record (GET /owner/{assetId})', testGetOwner)) passed++; else failed++;
if (await runTest('6. Token Reuse (caching verification)', testTokenReuse)) passed++; else failed++;
// Mutation tests — these modify asset state in CARD
logInfo('');
logInfo('=== Mutation Tests (modify asset state) ===');
logInfo('These tests exercise confirm/decline/redirect. They may return');
logInfo('4xx if the asset is not in the correct state — that is expected.');
logInfo('');
if (await runTest('7. Confirm Asset (POST /owner/{id}/confirm)', testConfirmAsset)) passed++; else failed++;
if (await runTest('8. Decline Asset (POST /owner/{id}/decline)', testDeclineAsset)) passed++; else failed++;
if (await runTest('9. Redirect Asset (POST /owner/{id}/{from}/redirect)', testRedirectAsset)) passed++; else failed++;
logInfo('');
logInfo('=== Summary ===');
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
if (discoveredTeam) logInfo('Team used: ' + discoveredTeam);
if (discoveredAssetId) logInfo('Asset used: ' + discoveredAssetId);
writeLog();
if (failed > 0) {
console.log('\nSome tests failed. Review the log above and card-uat-test.log for details.');
process.exit(1);
} else {
console.log('\nAll tests passed. Log saved to backend/scripts/card-uat-test.log');
process.exit(0);
}
}
function writeLog() {
const lines = results.map(r => {
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
if (r.data) {
const dataStr = typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2);
const truncated = dataStr.length > 2000
? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]'
: dataStr;
line += '\n ' + truncated.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);
});

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env node
// Diagnostic: check alignment between counts history dates and anomaly log dates
// Usage: node backend/scripts/diagnose-chart-alignment.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
function extractDate(ts) {
if (!ts) return '';
return ts.split('T')[0].split(' ')[0];
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Get counts history dates (same query as the API)
const countsRows = await dbAll(db,
`SELECT date FROM (
SELECT DATE(recorded_at) AS date,
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
FROM ivanti_counts_history
) WHERE rn = 1 ORDER BY date ASC`
);
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
// Get anomaly history (same query as the API)
const anomalyRows = await dbAll(db,
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 30`
);
console.log('=== Counts History Dates (last 10) ===');
const lastTen = countsRows.slice(-10);
for (const r of lastTen) {
console.log(` ${r.date}${fmtDate(r.date)}`);
}
console.log('\n=== Anomaly Log Entries with Activity ===');
for (const a of anomalyRows) {
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
const inCounts = countsDates.has(dateKey);
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
}
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
let missingCount = 0;
for (const a of anomalyRows) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
if (!countsDates.has(dateKey)) {
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
missingCount++;
}
}
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -1,410 +0,0 @@
#!/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);
// Truncate long data to keep logs readable (HTML error pages can be 50KB+)
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
console.log(' ' + truncated.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');
// Discover available issue types for this project
const projRes = await jiraApi.jiraGet('/rest/api/2/project/' + encodeURIComponent(projectKey));
assert(projRes.status === 200, 'Should be able to fetch project metadata. Got HTTP ' + projRes.status + ': ' + (projRes.body || '').substring(0, 300));
const projData = JSON.parse(projRes.body);
const availableTypes = (projData.issueTypes || []).filter(t => !t.subtask);
logInfo('Available issue types:', availableTypes.map(t => t.name));
// Determine which issue type to use: configured type first, then fallback order
const configuredType = jiraApi.JIRA_ISSUE_TYPE || 'Task';
const fallbackOrder = [configuredType, 'Story', 'Task', 'Bug'];
let issueTypeName = null;
for (const candidate of fallbackOrder) {
if (availableTypes.some(t => t.name === candidate)) {
issueTypeName = candidate;
break;
}
}
// If none of the preferred types exist, use the first available non-subtask type
if (!issueTypeName && availableTypes.length > 0) {
issueTypeName = availableTypes[0].name;
}
assert(issueTypeName, 'No usable issue type found in project ' + projectKey);
if (issueTypeName !== configuredType) {
logWarn('Configured JIRA_ISSUE_TYPE "' + configuredType + '" not available — falling back to "' + issueTypeName + '"');
}
const fields = {
project: { key: projectKey },
summary: '[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ' + new Date().toISOString(),
issuetype: { name: issueTypeName },
description: 'UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
};
// Epic type requires an Epic Name field — add it if creating an Epic
if (issueTypeName === 'Epic') {
fields.customfield_10004 = fields.summary; // Epic Name (standard Jira field ID)
}
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
let result = await jiraApi.createIssue(fields);
// If the first attempt fails with 400, try without description (some screens don't have it)
if (!result.ok && result.status === 400) {
const errBody = (result.body || '').substring(0, 500);
logWarn('Create failed with 400, retrying without description. Error: ' + errBody);
const retryFields = { ...fields };
delete retryFields.description;
result = await jiraApi.createIssue(retryFields);
}
// If still failing with 400 and we used Epic, try without the customfield_10004
// (Epic Name field ID varies across Jira instances)
if (!result.ok && result.status === 400 && issueTypeName === 'Epic') {
const errBody = (result.body || '').substring(0, 500);
logWarn('Epic create failed, retrying with alternate Epic Name field. Error: ' + errBody);
const retryFields = { ...fields };
delete retryFields.customfield_10004;
// Try common alternate Epic Name field IDs
retryFields.customfield_10011 = fields.summary;
result = await jiraApi.createIssue(retryFields);
}
assert(result.ok, 'Create issue should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
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, issueType: issueTypeName });
}
// ---------------------------------------------------------------------------
// 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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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');
// Use a broad time window to ensure results even on a quiet project
const jql = `project = ${projectKey} AND updated >= -24h ORDER BY updated DESC`;
logInfo('Searching with JQL:', jql);
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
assert(result.ok, 'Search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
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 HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
logInfo('Bulk search uses project-scoped JQL with project = ' + jiraApi.JIRA_PROJECT_KEY);
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 (JQL search)', 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 (GET /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) {
const dataStr = (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2));
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
line += '\n ' + truncated.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);
});