feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services
This commit is contained in:
@@ -41,3 +41,12 @@ JIRA_PROJECT_KEY=
|
||||
JIRA_ISSUE_TYPE=Task
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
JIRA_SKIP_TLS=false
|
||||
|
||||
# CARD Asset Ownership API (card.charter.com / card.caas.stage.charterlab.com)
|
||||
# OAuth Bearer token auth — service account must be onboarded with the CARD team.
|
||||
# Tokens are acquired automatically via Basic Auth and cached for 1 hour.
|
||||
CARD_API_URL=
|
||||
CARD_API_USER=
|
||||
CARD_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
CARD_SKIP_TLS=false
|
||||
|
||||
305
backend/helpers/cardApi.js
Normal file
305
backend/helpers/cardApi.js
Normal file
@@ -0,0 +1,305 @@
|
||||
// Shared CARD API helpers
|
||||
// Centralizes HTTP calls for the CARD asset ownership API.
|
||||
// Follows the same promise-based pattern as atlasApi.js, with the addition
|
||||
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
|
||||
//
|
||||
// CARD API versioning:
|
||||
// - Read endpoints (GET): /api/v1/...
|
||||
// - Mutation endpoints (POST): /api/v2/...
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const CARD_API_URL = process.env.CARD_API_URL || '';
|
||||
const CARD_API_USER = process.env.CARD_API_USER || '';
|
||||
const CARD_API_PASS = process.env.CARD_API_PASS || '';
|
||||
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
|
||||
|
||||
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Manager — OAuth Bearer token with 1-hour TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
|
||||
|
||||
function tokenIsValid() {
|
||||
if (!cachedToken) return false;
|
||||
// Refresh if within 60 seconds of expiry
|
||||
return cachedToken.expiresAt - Date.now() > 60_000;
|
||||
}
|
||||
|
||||
function invalidateToken() {
|
||||
cachedToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
|
||||
* Caches the token in memory with a 1-hour TTL.
|
||||
*/
|
||||
function acquireToken(timeout) {
|
||||
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
|
||||
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Basic ' + authString,
|
||||
'content-length': '0',
|
||||
},
|
||||
timeout: timeout || 15000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
return reject(new Error(
|
||||
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
|
||||
));
|
||||
}
|
||||
|
||||
// The CARD API returns the token as a JSON string or object.
|
||||
// Try to parse; fall back to raw body as the token string.
|
||||
let token;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
token = typeof parsed === 'string' ? parsed
|
||||
: parsed.token || parsed.access_token || data.trim();
|
||||
} catch (_) {
|
||||
// Response may be a plain token string (unquoted)
|
||||
token = data.trim();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
|
||||
}
|
||||
|
||||
cachedToken = {
|
||||
token,
|
||||
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
|
||||
};
|
||||
resolve(cachedToken.token);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
|
||||
*/
|
||||
async function ensureToken(timeout) {
|
||||
if (tokenIsValid()) return cachedToken.token;
|
||||
return acquireToken(timeout);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||
// ---------------------------------------------------------------------------
|
||||
async function cardRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const skipAuth = (options && options.skipAuth) || false;
|
||||
|
||||
async function doRequest(bearerToken) {
|
||||
const fullUrl = new URL(CARD_API_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = { 'accept': 'application/json' };
|
||||
|
||||
if (bearerToken) {
|
||||
headers['authorization'] = 'Bearer ' + bearerToken;
|
||||
}
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
headers,
|
||||
timeout,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
|
||||
});
|
||||
|
||||
if (bodyStr) req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Skip auth for the token endpoint itself
|
||||
if (skipAuth) {
|
||||
return doRequest(null);
|
||||
}
|
||||
|
||||
// Normal flow: ensure token → request → retry once on 401
|
||||
let token = await ensureToken(timeout);
|
||||
let result = await doRequest(token);
|
||||
|
||||
if (result.status === 401) {
|
||||
// Invalidate and retry exactly once
|
||||
invalidateToken();
|
||||
token = await ensureToken(timeout);
|
||||
result = await doRequest(token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function cardGet(urlPath, options) {
|
||||
return cardRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function cardPost(urlPath, body, options) {
|
||||
return cardRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level helpers used by the UAT test and routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const token = await acquireToken();
|
||||
return { ok: true, token: token.substring(0, 12) + '...' };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/teams — list all CARD teams.
|
||||
*/
|
||||
async function getTeams() {
|
||||
const res = await cardGet('/api/v1/teams');
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/team/{teamName}/assets — list assets for a team.
|
||||
*/
|
||||
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (disposition) params.set('disposition', disposition);
|
||||
if (page) params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize || 50));
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/confirm — confirm asset to a team.
|
||||
*/
|
||||
async function confirmAsset(assetId, teamName, updateToken, comment) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
if (comment) params.set('comment', comment);
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
|
||||
{ name: teamName }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/decline — decline asset from a team.
|
||||
*/
|
||||
async function declineAsset(assetId, teamName, updateToken, comment) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
if (comment) params.set('comment', comment);
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
|
||||
{ name: teamName }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams.
|
||||
*/
|
||||
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
const params = new URLSearchParams({ update_token: updateToken });
|
||||
const res = await cardPost(
|
||||
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
|
||||
{ name: toTeam }
|
||||
);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
cardRequest,
|
||||
cardGet,
|
||||
cardPost,
|
||||
testConnection,
|
||||
getTeams,
|
||||
getTeamAssets,
|
||||
getOwner,
|
||||
confirmAsset,
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
invalidateToken,
|
||||
};
|
||||
57
backend/migrations/add_return_classification.js
Normal file
57
backend/migrations/add_return_classification.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Migration: Add return_classification_json column to ivanti_sync_anomaly_log
|
||||
//
|
||||
// Stores the classification breakdown for returned findings (e.g., how many
|
||||
// returned due to BU reassignment back to team, severity re-escalation, etc.)
|
||||
//
|
||||
// Safe to re-run — uses ALTER TABLE with IF NOT EXISTS pattern.
|
||||
//
|
||||
// Usage: node backend/migrations/add_return_classification.js
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting return classification migration...');
|
||||
|
||||
function run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
// Check if column already exists
|
||||
const columns = await all(`PRAGMA table_info(ivanti_sync_anomaly_log)`);
|
||||
const hasColumn = columns.some(c => c.name === 'return_classification_json');
|
||||
|
||||
if (!hasColumn) {
|
||||
await run(`ALTER TABLE ivanti_sync_anomaly_log ADD COLUMN return_classification_json TEXT NOT NULL DEFAULT '{}'`);
|
||||
console.log('✓ Added return_classification_json column to ivanti_sync_anomaly_log');
|
||||
} else {
|
||||
console.log('✓ return_classification_json column already exists — skipping');
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
615
backend/routes/cardApi.js
Normal file
615
backend/routes/cardApi.js
Normal file
@@ -0,0 +1,615 @@
|
||||
// CARD Asset Ownership API Routes
|
||||
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
|
||||
// the two-step update_token flow for mutations.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
getTeams,
|
||||
getTeamAssets,
|
||||
getOwner,
|
||||
confirmAsset,
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — maps CARD API / token errors to client responses
|
||||
// ---------------------------------------------------------------------------
|
||||
function handleCardError(err, res) {
|
||||
const msg = err.message || String(err);
|
||||
console.error('[card-api]', msg);
|
||||
|
||||
// Token endpoint errors (from acquireToken rejections)
|
||||
if (msg.includes('Token acquisition failed')) {
|
||||
if (msg.includes('HTTP 401')) {
|
||||
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
|
||||
}
|
||||
if (msg.includes('HTTP 403')) {
|
||||
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
|
||||
}
|
||||
if (msg.includes('HTTP 525')) {
|
||||
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
|
||||
}
|
||||
}
|
||||
|
||||
// API call errors (after automatic 401 retry in helper)
|
||||
if (msg.includes('401')) {
|
||||
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
|
||||
}
|
||||
if (msg.includes('403')) {
|
||||
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
|
||||
}
|
||||
|
||||
// Catch-all
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createCardApiRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Returns whether the CARD API integration is configured.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({
|
||||
configured: false,
|
||||
error: 'CARD API is not configured.',
|
||||
missingVars,
|
||||
});
|
||||
}
|
||||
res.json({ configured: true });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams
|
||||
// Proxy CARD teams list.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTeams();
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
// CARD API wraps teams in { teams: [...], response_time: ... }
|
||||
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
|
||||
return res.json(teams);
|
||||
}
|
||||
|
||||
// Forward CARD error status
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams/:teamName/assets
|
||||
// Proxy team assets with required disposition filter.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { teamName } = req.params;
|
||||
const { disposition, page, page_size } = req.query;
|
||||
|
||||
if (!disposition) {
|
||||
return res.status(400).json({ error: 'disposition query parameter is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTeamAssets(teamName, {
|
||||
disposition,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: page_size ? parseInt(page_size, 10) : 50,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
|
||||
// Audit log for asset search (fire-and-forget)
|
||||
let resultCount = 0;
|
||||
if (body && typeof body === 'object' && typeof body.total === 'number') {
|
||||
resultCount = body.total;
|
||||
} else if (body && Array.isArray(body.assets)) {
|
||||
resultCount = body.assets.length;
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_search',
|
||||
entityType: 'card_asset',
|
||||
entityId: teamName,
|
||||
details: { disposition, resultCount },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /owner/:assetId
|
||||
// Proxy owner record lookup.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { assetId } = req.params;
|
||||
|
||||
try {
|
||||
const result = await getOwner(assetId);
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/confirm
|
||||
// Confirm asset to a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute confirm mutation
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
// Update queue item to complete
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_confirm',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed — leave queue item as pending
|
||||
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Confirm error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/decline
|
||||
// Decline asset from a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute decline mutation
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_decline',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Decline error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/redirect
|
||||
// Redirect asset from one team to another via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { fromTeam, toTeam, assetId } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (item.status !== 'pending') {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute redirect mutation
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_redirect',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Redirect error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createCardApiRouter;
|
||||
@@ -13,8 +13,8 @@
|
||||
// POST /notes — add a note to one or more (hostname, metric_id) pairs
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
// GET /trends — per-upload totals + per-team counts for time-series charts
|
||||
// GET /mttr — mean time to resolution per team
|
||||
// GET /top-recurring — chronic compliance gaps sorted by seen_count
|
||||
// GET /mttr — aging findings distribution by seen_count bucket and team
|
||||
// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end)
|
||||
// GET /category-trend — active counts per category per upload for stacked area chart
|
||||
|
||||
const express = require('express');
|
||||
@@ -240,6 +240,60 @@ function groupByHostname(rows, noteHostnames) {
|
||||
return Object.values(deviceMap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: bucket active items by age group and pivot per-team counts
|
||||
// ---------------------------------------------------------------------------
|
||||
const BUCKET_ORDER = ['1 cycle', '2–3 cycles', '4–6 cycles', '7+ cycles'];
|
||||
|
||||
function bucketAgingItems(items) {
|
||||
// Initialise empty buckets with all teams at zero
|
||||
const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
const buckets = {};
|
||||
for (const b of BUCKET_ORDER) {
|
||||
buckets[b] = { bucket: b, total: 0 };
|
||||
for (const t of teams) buckets[b][t] = 0;
|
||||
}
|
||||
|
||||
// Classify each item into a bucket
|
||||
for (const item of items) {
|
||||
const sc = item.seen_count;
|
||||
let label;
|
||||
if (sc === 1) label = '1 cycle';
|
||||
else if (sc >= 2 && sc <= 3) label = '2–3 cycles';
|
||||
else if (sc >= 4 && sc <= 6) label = '4–6 cycles';
|
||||
else label = '7+ cycles';
|
||||
|
||||
const team = item.team;
|
||||
buckets[label].total += 1;
|
||||
if (team in buckets[label]) {
|
||||
buckets[label][team] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Return in ascending age order
|
||||
return BUCKET_ORDER.map(b => buckets[b]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: compute waterfall chain from ordered upload records
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeWaterfall(uploads) {
|
||||
let start = 0;
|
||||
return uploads.map((row) => {
|
||||
const end = start + row.new_count + row.recurring_count - row.resolved_count;
|
||||
const entry = {
|
||||
date: row.report_date,
|
||||
start,
|
||||
new_count: row.new_count,
|
||||
recurring_count: row.recurring_count,
|
||||
resolved_count: row.resolved_count,
|
||||
end,
|
||||
};
|
||||
start = end;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1012,27 +1066,23 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
// Aging Findings Distribution — active findings bucketed by seen_count
|
||||
// with per-team breakdown for stacked bar chart.
|
||||
//
|
||||
// Response: { mttr: [{ team, avg_days, resolved_count }] }
|
||||
// Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.team,
|
||||
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
AND fu.report_date IS NOT NULL
|
||||
AND ru.report_date IS NOT NULL
|
||||
GROUP BY ci.team
|
||||
ORDER BY avg_days DESC`
|
||||
`SELECT COALESCE(seen_count, 1) AS seen_count, team
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'`
|
||||
);
|
||||
res.json({ mttr: rows });
|
||||
if (rows.length === 0) {
|
||||
return res.json({ aging: [] });
|
||||
}
|
||||
const aging = bucketAgingItems(rows);
|
||||
res.json({ aging });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /mttr error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -1041,23 +1091,24 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
// Net Change Waterfall — per-cycle net movement (start → +new →
|
||||
// +recurring → −resolved → end) computed from compliance_uploads.
|
||||
//
|
||||
// Response: { items: [{ team, metric_id, metric_desc, seen_count,
|
||||
// host_count }] } — limited to top 20
|
||||
// Response: { waterfall: [{ date, start, new_count, recurring_count,
|
||||
// resolved_count, end }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC, host_count DESC
|
||||
LIMIT 20`
|
||||
`SELECT id, report_date,
|
||||
COALESCE(new_count, 0) AS new_count,
|
||||
COALESCE(recurring_count, 0) AS recurring_count,
|
||||
COALESCE(resolved_count, 0) AS resolved_count
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC`
|
||||
);
|
||||
res.json({ items: rows });
|
||||
const waterfall = computeWaterfall(rows);
|
||||
res.json({ waterfall });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -1089,4 +1140,4 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createComplianceRouter;
|
||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };
|
||||
|
||||
@@ -275,6 +275,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
|
||||
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
||||
const currentIdsList = [...currentIds];
|
||||
const returnedArchiveIds = []; // track archive IDs of returned findings for classification
|
||||
if (currentIdsList.length > 0) {
|
||||
try {
|
||||
const archivedRecords = await dbAll(db,
|
||||
@@ -297,6 +298,7 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
|
||||
[record.id, severity]
|
||||
);
|
||||
returnedArchiveIds.push(record.id);
|
||||
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
||||
}
|
||||
}
|
||||
@@ -306,23 +308,38 @@ async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||
}
|
||||
|
||||
// Count returned findings for anomaly summary
|
||||
let returnedCount = 0;
|
||||
if (currentIdsList.length > 0) {
|
||||
let returnedCount = returnedArchiveIds.length;
|
||||
|
||||
// Classify returned findings by looking up the reason they were originally archived.
|
||||
// This tells us *why* they came back (e.g., BU reassignment back to team).
|
||||
const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||
for (const archiveId of returnedArchiveIds) {
|
||||
try {
|
||||
// Count how many ARCHIVED records transitioned to RETURNED in this cycle
|
||||
// (already handled above, just count them)
|
||||
const archivedForCount = await dbAll(db,
|
||||
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'RETURNED' AND last_transition_at >= datetime('now', '-1 minute')`
|
||||
// Find the most recent ARCHIVED transition reason for this archive record
|
||||
const transition = await dbGet(db,
|
||||
`SELECT reason FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ? AND to_state = 'ARCHIVED'
|
||||
ORDER BY transitioned_at DESC LIMIT 1`,
|
||||
[archiveId]
|
||||
);
|
||||
returnedCount = archivedForCount.length;
|
||||
if (transition && transition.reason) {
|
||||
// Reason format is either a plain key or "key:detail" (e.g., "bu_reassignment:SOME-BU")
|
||||
const reasonKey = transition.reason.split(':')[0];
|
||||
if (reasonKey in returnClassification) {
|
||||
returnClassification[reasonKey]++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — returnedCount stays 0
|
||||
// Non-fatal — skip this finding's classification
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned, checked ${currentIdsList.length} current findings against archive`);
|
||||
if (returnedCount > 0) {
|
||||
console.log(`[Archive Detection] Return classification:`, returnClassification);
|
||||
}
|
||||
|
||||
return { disappearedIds, returnedCount };
|
||||
return { disappearedIds, returnedCount, returnClassification };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -763,9 +780,9 @@ async function syncFindings(db) {
|
||||
|
||||
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
||||
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
||||
let archiveResult = { disappearedIds: [], returnedCount: 0 };
|
||||
let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
||||
try {
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0 };
|
||||
archiveResult = await detectArchiveChanges(db, previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||
}
|
||||
@@ -812,7 +829,8 @@ async function syncFindings(db) {
|
||||
closedCountDelta,
|
||||
archiveResult.disappearedIds.length,
|
||||
archiveResult.returnedCount,
|
||||
classificationBreakdown
|
||||
classificationBreakdown,
|
||||
archiveResult.returnClassification || {}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
|
||||
@@ -1060,20 +1078,24 @@ async function runBUDriftChecker(db, newlyArchivedIds, apiKey, clientId, skipTls
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anomaly Summary — compute and store post-sync anomaly report
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown) {
|
||||
async function computeAnomalySummary(db, openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) {
|
||||
try {
|
||||
const isSignificant = newlyArchivedCount > 5 ? 1 : 0;
|
||||
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
||||
const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {});
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_sync_anomaly_log
|
||||
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, is_significant)
|
||||
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?)`,
|
||||
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, isSignificant]
|
||||
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant)
|
||||
VALUES (datetime('now'), ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, returnClassificationJson, isSignificant]
|
||||
);
|
||||
|
||||
console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${!!isSignificant}`);
|
||||
console.log(`[Anomaly Summary] Classification:`, classificationBreakdown);
|
||||
if (returnedCount > 0) {
|
||||
console.log(`[Anomaly Summary] Return classification:`, returnClassificationBreakdown);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
|
||||
}
|
||||
@@ -1219,13 +1241,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
try {
|
||||
const row = await dbGet(db,
|
||||
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 1`
|
||||
);
|
||||
if (!row) return res.json({ anomaly: null });
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
||||
let return_classification = {};
|
||||
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
||||
res.json({
|
||||
anomaly: {
|
||||
id: row.id,
|
||||
@@ -1235,6 +1259,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
return_classification,
|
||||
is_significant: !!row.is_significant
|
||||
}
|
||||
});
|
||||
@@ -1265,7 +1290,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
if (from && to) {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
WHERE sync_timestamp >= ? AND sync_timestamp <= ?
|
||||
ORDER BY sync_timestamp DESC`,
|
||||
@@ -1274,7 +1299,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
} else {
|
||||
rows = await dbAll(db,
|
||||
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
||||
newly_archived_count, returned_count, classification_json, is_significant
|
||||
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
||||
FROM ivanti_sync_anomaly_log
|
||||
ORDER BY sync_timestamp DESC LIMIT 30`
|
||||
);
|
||||
@@ -1283,6 +1308,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
const history = rows.map(row => {
|
||||
let classification = {};
|
||||
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
||||
let return_classification = {};
|
||||
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
||||
return {
|
||||
sync_timestamp: row.sync_timestamp,
|
||||
open_count_delta: row.open_count_delta,
|
||||
@@ -1290,6 +1317,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
newly_archived_count: row.newly_archived_count,
|
||||
returned_count: row.returned_count,
|
||||
classification,
|
||||
return_classification,
|
||||
is_significant: !!row.is_significant
|
||||
};
|
||||
});
|
||||
|
||||
388
backend/scripts/card-granite-lookup.js
Normal file
388
backend/scripts/card-granite-lookup.js
Normal 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);
|
||||
});
|
||||
486
backend/scripts/card-uat-test.js
Normal file
486
backend/scripts/card-uat-test.js
Normal 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);
|
||||
});
|
||||
@@ -25,9 +25,10 @@ const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const createComplianceRouter = require('./routes/compliance');
|
||||
const { createComplianceRouter } = require('./routes/compliance');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -242,6 +243,9 @@ app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
||||
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||
|
||||
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
||||
app.use('/api/card', createCardApiRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
Reference in New Issue
Block a user