feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services

This commit is contained in:
root
2026-05-01 17:15:41 +00:00
parent 8df961cce8
commit 15abf8bae4
21 changed files with 3639 additions and 210 deletions

View File

@@ -0,0 +1,388 @@
#!/usr/bin/env node
// ==========================================================================
// CARD → Granite Lookup Script (v2)
// ==========================================================================
// Queries CARD team assets endpoint (which returns full enriched records
// including ncim_discovery with EQUIP_INST_ID) for the 109 reassigned IPs
// from the findings-count investigation Appendix C.
//
// Generates:
// docs/card-lookup-results.csv — full CARD data for review
// docs/granite-reassignment-upload.csv — Team_Device Loader format
//
// Usage:
// cd backend
// node scripts/card-granite-lookup.js
// ==========================================================================
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const cardApi = require('../helpers/cardApi');
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// IP → hostname mapping from Appendix C
// ---------------------------------------------------------------------------
const REASSIGNED = {
// With approved FP workflows (58)
'98.120.0.78': 'syn-098-120-000-078', '98.120.32.185': 'syn-098-120-032-185',
'10.240.78.177': 'mon15-agg-sw', '10.240.78.176': 'mon16-agg-sw',
'10.240.78.133': 'mon15-sw14', '10.240.78.130': 'mon15-sw11',
'10.240.78.150': 'mon19-sw3', '10.240.78.107': 'mon16-sw2',
'10.240.78.110': 'mon16-sw5', '10.240.78.106': 'mon16-sw1',
'10.240.78.149': 'mon19-sw2', '10.240.78.154': 'mon19-sw7',
'10.240.78.111': 'mon16-sw6', '10.240.78.153': 'mon19-sw6',
'10.240.78.132': 'mon15-sw13', '10.240.78.115': 'mon16-sw10',
'10.240.78.109': 'mon16-sw4', '10.240.78.112': 'mon16-sw7',
'10.240.78.119': 'mon16-sw14', '10.240.78.114': 'mon16-sw9',
'10.240.78.118': 'mon16-sw13', '10.240.78.117': 'mon16-sw12',
'10.240.78.108': 'mon16-sw3', '10.240.78.155': 'mon19-sw8',
'10.240.78.157': 'mon19-sw10', '10.240.78.151': 'mon19-sw4',
'10.240.78.116': 'mon16-sw11', '10.240.78.152': 'mon19-sw5',
'10.240.78.161': 'mon19-sw14', '10.240.78.160': 'mon19-sw13',
'10.240.78.159': 'mon19-sw12', '10.240.78.158': 'mon19-sw11',
'10.240.78.123': 'mon15-sw4', '10.240.78.137': 'mon20-sw4',
'10.240.78.148': 'mon19-sw1', '10.240.78.125': 'mon15-sw6',
'10.240.78.156': 'mon19-sw9', '10.241.0.63': '',
'10.244.11.51': 'apc01se1shcc-n01-bmc', '172.27.72.1': '',
'96.37.185.145': '', '10.240.78.170': 'mon17-sw9',
'10.240.78.172': 'mon17-sw11', '10.240.78.169': 'mon17-sw8',
'10.240.78.166': 'mon17-sw5', '10.240.78.174': 'mon17-sw13',
'10.240.78.173': 'mon17-sw12', '10.240.78.167': 'mon17-sw6',
'10.240.78.175': 'mon17-sw14', '10.240.78.168': 'mon17-sw7',
'10.240.78.171': 'mon17-sw10', '66.61.128.10': 'syn-066-061-128-010',
'66.61.128.233': 'apa01se1shcc-bvi101-secondary',
'66.61.128.49': 'syn-066-061-128-049', '66.61.128.18': 'syn-066-061-128-018',
'10.244.4.26': '', '10.244.11.5': '', '10.244.11.6': '',
// With rejected FP workflows (8)
'10.244.4.55': 'apc15se1shcc-n03', '10.244.11.53': 'apc01se1shcc-n03-bmc',
'10.244.4.30': '', '10.244.11.63': 'apc04se1shcc-n01-cimc',
'24.28.208.125': '', '24.28.210.101': 'syn-024-028-210-101',
'10.244.11.27': '', '10.240.1.203': '',
// Without FP workflows (43)
'10.240.78.20': '', '172.16.1.229': '',
'10.244.11.96': '', '10.244.11.54': 'apc02se1shcc-n01-cimc',
'10.244.4.51': 'apc14se1shcc-n02', '10.244.11.86': '',
'10.244.11.55': 'apc02se1shcc-n02-cimc', '24.28.208.105': 'syn-024-028-208-105',
'10.244.4.50': 'apc14se1shcc-n01', '10.244.4.53': 'apc15se1shcc-n01',
'10.244.11.73': 'apc07se1shcc-n02-cimc', '10.244.11.64': 'apc04se1shcc-n02-cimc',
'10.244.4.54': 'apc15se1shcc-n02', '10.244.4.28': '',
'10.244.11.94': '', '10.241.0.43': 'c220-wzp27340ss5',
'10.244.11.56': 'apc02se1shcc-n03-cimc', '10.244.11.66': 'apc05se1shcc-n01-bmc',
'10.244.4.47': 'apc13se1shcc-n01', '10.244.4.49': 'apc13se1shcc-n03',
'10.244.4.52': 'apc14se1shcc-n03', '10.244.11.72': 'apc07se1shcc-n01-cimc',
'10.244.4.25': 'apc02ctsbcom7-n03-cimc', '10.244.4.29': '',
'10.244.11.74': 'apc07se1shcc-n03-cimc', '10.244.4.48': 'apc13se1shcc-n02',
'10.244.11.65': 'apc04se1shcc-n03-cimc', '10.244.4.24': 'apc02ctsbcom7-n02-cimc',
'10.244.11.87': '', '10.244.11.68': 'apc05se1shcc-n03-bmc',
'10.244.11.67': 'apc05se1shcc-n02-bmc', '10.244.4.23': 'apc02ctsbcom7-n01-cimc',
'10.244.11.57': '', '10.244.11.95': '',
'98.120.32.145': 'syn-098-120-032-145', '98.120.0.129': 'syn-098-120-000-129',
'68.114.184.84': 'rphy-runner-vecima',
};
const TARGET_IPS = new Set(Object.keys(REASSIGNED));
// ---------------------------------------------------------------------------
// Fetch all assets for both teams, then match against our IP list
// ---------------------------------------------------------------------------
async function fetchTeamAssets(teamName) {
const allAssets = [];
let page = 1;
const pageSize = 200;
while (true) {
// Fetch confirmed assets (these have the richest data)
const result = await cardApi.getTeamAssets(teamName, {
disposition: 'confirmed',
page,
pageSize,
});
if (!result.ok) {
console.error(` Failed to fetch ${teamName} page ${page}: HTTP ${result.status}`);
break;
}
let data;
try { data = JSON.parse(result.body); } catch (_) { break; }
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
allAssets.push(...assets);
const total = data.total || assets.length;
console.log(` ${teamName} page ${page}: ${assets.length} assets (total: ${total})`);
if (allAssets.length >= total || assets.length === 0) break;
page++;
}
return allAssets;
}
function extractIPFromAssetId(assetId) {
// Asset IDs are like "10.240.78.110-CTEC" — strip the suffix
if (!assetId) return null;
const parts = assetId.split('-');
// Rejoin all but the last part (the suffix like CTEC, NATL, etc.)
// But only if the last part looks like a suffix (not a number)
const last = parts[parts.length - 1];
if (/^\d+$/.test(last)) return assetId; // All numeric, probably just an IP
return parts.slice(0, -1).join('-');
}
function extractGraniteData(asset) {
const id = asset._id || '';
const ip = extractIPFromAssetId(id);
const flags = (asset.card_flags && asset.card_flags[0]) || {};
const ncim = asset.ncim_discovery || [];
const qualys = asset.qualys_hosts || [];
const ivanti = asset.ivanti_assets || [];
const granite = asset.netops_granite_allips || null;
const iseGranite = asset.ise_granite_equipment || null;
// Extract EQUIP_INST_ID from ncim_discovery (primary source)
let equipInstId = null;
let graniteTeam = null;
let entityId = null;
let sysLocation = null;
let ncimHostname = null;
if (ncim.length > 0) {
equipInstId = ncim[0].EQUIP_INST_ID || null;
graniteTeam = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
entityId = ncim[0].ENTITYID || null;
sysLocation = ncim[0].SYSLOCATION || null;
ncimHostname = ncim[0].HOSTNAME || null;
}
// Fallback: check netops_granite_allips
if (!equipInstId && granite && Array.isArray(granite) && granite.length > 0) {
equipInstId = granite[0].EQUIP_INST_ID || null;
}
// Fallback: check ise_granite_equipment
if (!equipInstId && iseGranite && Array.isArray(iseGranite) && iseGranite.length > 0) {
equipInstId = iseGranite[0].EQUIP_INST_ID || null;
}
const hostname = ncimHostname
|| (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|| (ivanti.length > 0 && ivanti[0].hostName)
|| '';
const confirmedTeam = asset.owner && asset.owner.confirmed
? asset.owner.confirmed.name : null;
return {
ip,
assetId: id,
hostname,
equipInstId,
graniteTeam,
entityId,
sysLocation,
confirmedTeam,
deviceId: flags.CARD_DEVICE_ID || null,
asn: flags.CARD_ASN || null,
vendorModel: (flags.CARD_VENDOR_MODEL || []).map(v => v.vendor_model || v).join(', '),
status: flags.status || null,
};
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log('=== CARD → Granite Lookup (v2 — team assets endpoint) ===');
console.log(`Target IPs: ${TARGET_IPS.size}`);
console.log(`CARD_API_URL: ${process.env.CARD_API_URL}`);
console.log('');
if (!cardApi.isConfigured) {
console.error('CARD API is not configured.');
process.exit(1);
}
// Fetch assets from both teams
const teams = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
const allAssets = [];
for (const team of teams) {
console.log(`Fetching ${team}...`);
const assets = await fetchTeamAssets(team);
allAssets.push(...assets);
console.log(` Total: ${assets.length} assets\n`);
}
// Also fetch candidate/unconfirmed in case some were reassigned
for (const team of teams) {
for (const disp of ['candidate', 'unconfirmed']) {
console.log(`Fetching ${team} (${disp})...`);
try {
const result = await cardApi.getTeamAssets(team, { disposition: disp, pageSize: 200 });
if (result.ok) {
const data = JSON.parse(result.body);
const assets = Array.isArray(data) ? data : (data.assets || data.results || []);
allAssets.push(...assets);
console.log(` ${assets.length} assets`);
}
} catch (_) { /* skip */ }
}
}
console.log(`\nTotal assets fetched: ${allAssets.length}`);
// Build IP → asset map
const ipMap = new Map();
for (const asset of allAssets) {
const id = asset._id || '';
const ip = extractIPFromAssetId(id);
if (ip && !ipMap.has(ip)) {
ipMap.set(ip, asset);
}
}
console.log(`Unique IPs in CARD: ${ipMap.size}`);
// Match against our target IPs
const matched = [];
const notFound = [];
for (const ip of TARGET_IPS) {
const asset = ipMap.get(ip);
if (asset) {
matched.push(extractGraniteData(asset));
} else {
notFound.push(ip);
}
}
// For IPs not found in team assets, fall back to individual owner lookup
if (notFound.length > 0) {
console.log(`\n${notFound.length} IPs not in team assets — trying individual owner lookups...`);
const SUFFIXES = ['CTEC', 'NATL', 'TWC', 'BHN', 'CHTR'];
const stillNotFound = [];
for (const ip of notFound) {
let found = false;
for (const suffix of SUFFIXES) {
try {
const result = await cardApi.getOwner(`${ip}-${suffix}`);
if (result.ok) {
const data = JSON.parse(result.body);
// Owner endpoint is slim — extract what we can
const ncim = data.ncim_discovery || [];
matched.push({
ip,
assetId: data._id || `${ip}-${suffix}`,
hostname: REASSIGNED[ip] || '',
equipInstId: ncim.length > 0 ? (ncim[0].EQUIP_INST_ID || null) : null,
graniteTeam: ncim.length > 0 ? (ncim[0].GRANITE_RESP_TEAM || null) : null,
entityId: ncim.length > 0 ? (ncim[0].ENTITYID || null) : null,
sysLocation: ncim.length > 0 ? (ncim[0].SYSLOCATION || null) : null,
confirmedTeam: data.owner && data.owner.confirmed ? data.owner.confirmed.name : null,
deviceId: null,
asn: null,
vendorModel: '',
status: null,
});
found = true;
break;
}
} catch (_) { /* continue */ }
}
if (!found) stillNotFound.push(ip);
}
if (stillNotFound.length > 0) {
console.log(`\n${stillNotFound.length} IPs not found anywhere in CARD:`);
stillNotFound.forEach(ip => console.log(` ${ip} (${REASSIGNED[ip] || 'no hostname'})`));
}
}
// Sort by IP
matched.sort((a, b) => {
const aParts = a.ip.split('.').map(Number);
const bParts = b.ip.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (aParts[i] !== bParts[i]) return aParts[i] - bParts[i];
}
return 0;
});
// Summary
const withEquipId = matched.filter(r => r.equipInstId);
const withoutEquipId = matched.filter(r => !r.equipInstId);
console.log('\n=== Summary ===');
console.log(`Matched in CARD: ${matched.length}`);
console.log(`With EQUIP_INST_ID: ${withEquipId.length}`);
console.log(`Without EQUIP_INST_ID: ${withoutEquipId.length}`);
// Print results
console.log('\n=== Results with EQUIP_INST_ID ===');
console.log('IP Address | EQUIP_INST_ID | Hostname | Granite Team');
console.log('-'.repeat(100));
for (const r of withEquipId) {
console.log(`${r.ip.padEnd(20)} | ${String(r.equipInstId).padEnd(13)} | ${(r.hostname || '').padEnd(30)} | ${r.graniteTeam || '-'}`);
}
if (withoutEquipId.length > 0) {
console.log('\n=== Results WITHOUT EQUIP_INST_ID ===');
for (const r of withoutEquipId) {
console.log(` ${r.ip.padEnd(20)} ${(r.hostname || REASSIGNED[r.ip] || '').padEnd(30)} confirmed: ${r.confirmedTeam || '-'}`);
}
}
// Write full CSV
const csvPath = path.join(__dirname, '..', '..', 'docs', 'card-lookup-results.csv');
const csvHeader = 'IP Address,CARD Asset ID,Hostname,EQUIP_INST_ID,Granite Team,Entity ID,SysLocation,Confirmed Team,Device ID,ASN,Vendor Model,Status';
const csvRows = matched.map(r =>
[r.ip, r.assetId, r.hostname, r.equipInstId, r.graniteTeam, r.entityId, r.sysLocation, r.confirmedTeam, r.deviceId, r.asn, r.vendorModel, r.status]
.map(v => v === null || v === undefined ? '' : `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
fs.writeFileSync(csvPath, csvHeader + '\n' + csvRows.join('\n') + '\n', 'utf8');
console.log(`\nFull CSV: ${csvPath}`);
// Write Granite Team_Device Loader CSV
const graniteHeaders = [
'DELETE', 'SET_CONFIRMED', 'EQUIPMENT CLASS', 'EQUIP_INST_ID', 'SITE_NAME',
'EQUIP_NAME', 'EQUIP_TEMPLATE', 'EQUIP_STATUS',
'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM',
'UDA#IP_ADDRESSING#IPV4_ADDRESS',
'UDA#IP_ADDRESSING#MAC ADDRESS', 'UDA#IP_ADDRESSING#MGMT_IP_ASN', 'SERIALNUMBER',
];
const graniteRows = withEquipId.map(r => [
'', // DELETE
'', // SET_CONFIRMED
'S', // EQUIPMENT CLASS (Shelf)
r.equipInstId, // EQUIP_INST_ID
'', // SITE_NAME
r.hostname || REASSIGNED[r.ip] || '', // EQUIP_NAME
'', // EQUIP_TEMPLATE
'', // EQUIP_STATUS
'NTS-AEO-STEAM', // RESPONSIBLE TEAM
r.ip, // IPV4_ADDRESS
'', // MAC ADDRESS
r.asn || '', // MGMT_IP_ASN
r.deviceId || '', // SERIALNUMBER
]);
const granitePath = path.join(__dirname, '..', '..', 'docs', 'granite-reassignment-upload.csv');
const graniteContent = [
graniteHeaders.join(','),
...graniteRows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
].join('\n');
fs.writeFileSync(granitePath, graniteContent + '\n', 'utf8');
console.log(`Granite upload CSV (${withEquipId.length} rows): ${granitePath}`);
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,486 @@
#!/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);
});