487 lines
21 KiB
JavaScript
487 lines
21 KiB
JavaScript
#!/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);
|
|
});
|