#!/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); });