chore: deep cleanup of test scripts and implement glassmorphism UI enhancements
This commit is contained in:
1 parent
7777b62305
commit
58040d6e6c
81 files changed
+2247
-6734
No files matched your search
@@ -0,0 +1,3 @@
|
||||
const db = require('better-sqlite3')('backend/netify_data.db');
|
||||
console.log('Devices:', db.prepare("SELECT * FROM devices WHERE ip_address = '192.168.9.2'").all());
|
||||
console.log('Discovery:', db.prepare("SELECT * FROM intel_device_discovery WHERE ip_address = '192.168.9.2'").all());
|
||||
+140
-21
@@ -974,6 +974,25 @@ function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null, search
|
||||
}
|
||||
const flowMap = new Map(flowsBandwidth.map(f => [f.src_ip, f]));
|
||||
|
||||
// Build historical MAC lookup from ALL flows (not just latest snapshot)
|
||||
// This catches devices that appeared before with a MAC address
|
||||
const historicalMacs = agentUuid
|
||||
? d.prepare(`
|
||||
SELECT src_ip, src_mac
|
||||
FROM flows
|
||||
WHERE ${siteClause} AND agent_uuid = @agentUuid AND src_mac IS NOT NULL
|
||||
GROUP BY src_ip
|
||||
ORDER BY COUNT(*) DESC
|
||||
`).all({ siteUuid, agentUuid })
|
||||
: d.prepare(`
|
||||
SELECT src_ip, src_mac
|
||||
FROM flows
|
||||
WHERE ${siteClause} AND agent_uuid IS NULL AND src_mac IS NOT NULL
|
||||
GROUP BY src_ip
|
||||
ORDER BY COUNT(*) DESC
|
||||
`).all({ siteUuid });
|
||||
const historicalMacMap = new Map(historicalMacs.map(f => [f.src_ip, f.src_mac]));
|
||||
|
||||
// Get all devices in latest snapshot from devices table
|
||||
const devices = agentUuid
|
||||
? d.prepare(`SELECT * FROM devices WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at`).all({ siteUuid, agentUuid, fetched_at: latest.t })
|
||||
@@ -988,8 +1007,19 @@ function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null, search
|
||||
const dbMan = dbDev ? dbDev.manufacturer : (intelInfo ? intelInfo.manufacturer : null);
|
||||
const dbType = intelInfo ? intelInfo.device_type : null;
|
||||
|
||||
const meta = resolveDeviceMetadata(ip, mac, dbLabel, dbMan, dbType);
|
||||
const isRouted = mac === '04:f4:1c:ce:c2:e6' && ip !== '10.6.50.25' && ip !== '10.6.12.242';
|
||||
// Enrich MAC — fallback chain:
|
||||
// 1. devices.mac_address (most accurate, from API)
|
||||
// 2. intel_device_discovery.mac_address (from device discovery intel)
|
||||
// 3. flows.src_mac latest snapshot
|
||||
// 4. flows.src_mac historical (all time)
|
||||
const resolvedMac = mac
|
||||
|| (intelInfo && intelInfo.mac_address ? intelInfo.mac_address : null)
|
||||
|| (flowInfo && flowInfo.src_mac ? flowInfo.src_mac : null)
|
||||
|| historicalMacMap.get(ip)
|
||||
|| null;
|
||||
|
||||
const meta = resolveDeviceMetadata(ip, resolvedMac, dbLabel, dbMan, dbType);
|
||||
const isRouted = resolvedMac === '04:f4:1c:ce:c2:e6' && ip !== '10.6.50.25' && ip !== '10.6.12.242';
|
||||
|
||||
const download = flowInfo ? flowInfo.flow_download : (dbDev ? dbDev.download : 0);
|
||||
const upload = flowInfo ? flowInfo.flow_upload : (dbDev ? dbDev.upload : 0);
|
||||
@@ -998,7 +1028,7 @@ function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null, search
|
||||
id: dbDev ? dbDev.id : null,
|
||||
site_uuid: dbDev ? dbDev.site_uuid : siteUuid,
|
||||
fetched_at: latest.t,
|
||||
mac_address: mac,
|
||||
mac_address: resolvedMac,
|
||||
ip_address: ip,
|
||||
device_label: meta.label,
|
||||
device_type: meta.type,
|
||||
@@ -1029,11 +1059,9 @@ function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null, search
|
||||
|
||||
resolved.sort((a, b) => b.download - a.download);
|
||||
|
||||
// If agent is specified, only return devices that have some traffic or are active
|
||||
// We no longer filter out devices with 0 traffic for agents.
|
||||
// This allows the Devices page to show offline/idle devices properly.
|
||||
let filtered = resolved;
|
||||
if (agentUuid) {
|
||||
filtered = resolved.filter(d => d.total > 0);
|
||||
}
|
||||
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
@@ -1861,13 +1889,24 @@ function insertVPNDetection(rows, fetchedAt, siteUuid, agentUuid) {
|
||||
// (karena event ini tidak diposting ulang tiap menit, simpan kumulatif)
|
||||
function getIntelData(table, limit = 100, siteUuid = null, agentUuid = null) {
|
||||
const d = getDB();
|
||||
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
|
||||
|
||||
if (!agentUuid) {
|
||||
// Admin mode: return latest global snapshot (agent_uuid IS NULL)
|
||||
return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid });
|
||||
}
|
||||
|
||||
// Agent mode: try agent_uuid filter first (direct, most accurate)
|
||||
const directResult = d.prepare(`SELECT * FROM ${table} WHERE agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT @limit`).all({ agentUuid, limit });
|
||||
if (directResult.length > 0) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
// Fallback: MAC-based filter for tables that may have been saved without agent_uuid
|
||||
if (AGENT_MAC_MAP[agentUuid]) {
|
||||
const macs = AGENT_MAC_MAP[agentUuid];
|
||||
const placeholders = macs.map(() => '?').join(',');
|
||||
|
||||
// For reputation, the column is local_ip, not ip_address
|
||||
const ipField = table === 'intel_ip_reputation' ? 'local_ip' : 'ip_address';
|
||||
|
||||
return d.prepare(`
|
||||
SELECT * FROM ${table}
|
||||
WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
|
||||
@@ -1875,7 +1914,8 @@ function getIntelData(table, limit = 100, siteUuid = null, agentUuid = null) {
|
||||
LIMIT ?
|
||||
`).all(...macs, ...macs, limit);
|
||||
}
|
||||
return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND (@agentUuid IS NULL OR agent_uuid = @agentUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid, agentUuid });
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getIntelStats(siteUuid = null, agentUuid = null) {
|
||||
@@ -1887,19 +1927,28 @@ function getIntelStats(siteUuid = null, agentUuid = null) {
|
||||
];
|
||||
const counts = {};
|
||||
|
||||
const macs = agentUuid ? AGENT_MAC_MAP[agentUuid] : null;
|
||||
const placeholders = macs ? macs.map(() => '?').join(',') : '';
|
||||
|
||||
for (const t of tables) {
|
||||
try {
|
||||
if (agentUuid && macs) {
|
||||
const ipField = t === 'intel_ip_reputation' ? 'local_ip' : 'ip_address';
|
||||
counts[t] = d.prepare(`
|
||||
SELECT COUNT(*) as n FROM ${t}
|
||||
WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
|
||||
`).get(...macs, ...macs)?.n ?? 0;
|
||||
if (!agentUuid) {
|
||||
// Admin: count global (agent_uuid IS NULL)
|
||||
counts[t] = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE agent_uuid IS NULL`).get()?.n ?? 0;
|
||||
} else {
|
||||
counts[t] = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND (@agentUuid IS NULL OR agent_uuid = @agentUuid)`).get({ siteUuid, agentUuid })?.n ?? 0;
|
||||
// Agent mode: try agent_uuid directly first
|
||||
const directCount = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE agent_uuid = ?`).get(agentUuid)?.n ?? 0;
|
||||
if (directCount > 0) {
|
||||
counts[t] = directCount;
|
||||
} else if (AGENT_MAC_MAP[agentUuid]) {
|
||||
// Fallback MAC-based
|
||||
const macs = AGENT_MAC_MAP[agentUuid];
|
||||
const placeholders = macs.map(() => '?').join(',');
|
||||
const ipField = t === 'intel_ip_reputation' ? 'local_ip' : 'ip_address';
|
||||
counts[t] = d.prepare(`
|
||||
SELECT COUNT(*) as n FROM ${t}
|
||||
WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
|
||||
`).get(...macs, ...macs)?.n ?? 0;
|
||||
} else {
|
||||
counts[t] = 0;
|
||||
}
|
||||
}
|
||||
} catch { counts[t] = 0; }
|
||||
}
|
||||
@@ -1917,10 +1966,17 @@ function getDataInterval() {
|
||||
|
||||
module.exports = {
|
||||
getUserByUsername,
|
||||
getUserByAgentUuid,
|
||||
updateUserPassword,
|
||||
updateUserUsername,
|
||||
updateUserAccountName,
|
||||
updateUserProfilePicture,
|
||||
// Admin user management
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
createAgentUser,
|
||||
adminUpdateUser,
|
||||
deleteAgentUser,
|
||||
syncAgentUsers,
|
||||
getDB,
|
||||
getDataInterval,
|
||||
@@ -1953,6 +2009,20 @@ function getUserByUsername(username) {
|
||||
return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
}
|
||||
|
||||
// Returns the canonical user for an agent_uuid (prefers the one with account_name set)
|
||||
function getUserByAgentUuid(agentUuid) {
|
||||
const d = getDB();
|
||||
// First: find a user with account_name set for this agent
|
||||
const withName = d.prepare(
|
||||
"SELECT * FROM users WHERE agent_uuid = ? AND role = 'AGENT_VIEWER' AND account_name IS NOT NULL ORDER BY id ASC LIMIT 1"
|
||||
).get(agentUuid);
|
||||
if (withName) return withName;
|
||||
// Fallback: any user for this agent
|
||||
return d.prepare(
|
||||
"SELECT * FROM users WHERE agent_uuid = ? AND role = 'AGENT_VIEWER' ORDER BY id ASC LIMIT 1"
|
||||
).get(agentUuid);
|
||||
}
|
||||
|
||||
function updateUserPassword(userId, newPasswordHash) {
|
||||
return getDB().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newPasswordHash, userId);
|
||||
}
|
||||
@@ -1969,6 +2039,55 @@ function updateUserProfilePicture(userId, filename) {
|
||||
return getDB().prepare('UPDATE users SET profile_picture = ? WHERE id = ?').run(filename, userId);
|
||||
}
|
||||
|
||||
// ─── ADMIN USER MANAGEMENT ──────────────────────────────────────────────────
|
||||
|
||||
function getAllUsers() {
|
||||
return getDB().prepare(
|
||||
'SELECT id, username, role, site_uuid, agent_uuid, account_name, profile_picture, created_at FROM users ORDER BY role DESC, id ASC'
|
||||
).all();
|
||||
}
|
||||
|
||||
function getUserById(userId) {
|
||||
return getDB().prepare(
|
||||
'SELECT id, username, role, site_uuid, agent_uuid, account_name, profile_picture, created_at FROM users WHERE id = ?'
|
||||
).get(userId);
|
||||
}
|
||||
|
||||
function createAgentUser(username, passwordHash, accountName, agentUuid, siteUuid) {
|
||||
const d = getDB();
|
||||
// Check username unique
|
||||
const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) throw new Error('Username sudah digunakan');
|
||||
return d.prepare(
|
||||
'INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid, account_name) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(username, passwordHash, 'AGENT_VIEWER', siteUuid || null, agentUuid || null, accountName || null);
|
||||
}
|
||||
|
||||
function adminUpdateUser(userId, fields) {
|
||||
const d = getDB();
|
||||
const allowed = ['username', 'password_hash', 'account_name', 'agent_uuid', 'profile_picture'];
|
||||
const sets = [];
|
||||
const vals = [];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (allowed.includes(k) && v !== undefined) {
|
||||
sets.push(`${k} = ?`);
|
||||
vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return { changes: 0 };
|
||||
vals.push(userId);
|
||||
return d.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
||||
}
|
||||
|
||||
function deleteAgentUser(userId) {
|
||||
const d = getDB();
|
||||
// Prevent deleting SUPER_ADMIN
|
||||
const user = d.prepare('SELECT role FROM users WHERE id = ?').get(userId);
|
||||
if (!user) throw new Error('User tidak ditemukan');
|
||||
if (user.role === 'SUPER_ADMIN') throw new Error('Tidak bisa menghapus akun SUPER_ADMIN');
|
||||
return d.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function syncAgentUsers(agents) {
|
||||
const d = getDB();
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
+95
-62
@@ -1886,13 +1886,15 @@ async function fetchAppDetails(appLabel, agentUuid = null) {
|
||||
|
||||
// ── Totals: from flows (protocol-level) OR bandwidth_apps (brand-level) ──
|
||||
// IMPORTANT: use MAX(download) from latest snapshot — NOT SUM() of all history!
|
||||
const flowTotalRow = d.prepare(`
|
||||
let flowQuery = `
|
||||
SELECT SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE app_label = ?
|
||||
`).get(appLabel);
|
||||
WHERE app_label = @appLabel
|
||||
`;
|
||||
if (agentUuid) flowQuery += ` AND agent_uuid = @agentUuid`;
|
||||
const flowTotalRow = d.prepare(flowQuery).get({ appLabel, agentUuid });
|
||||
|
||||
let total_download = flowTotalRow?.download ?? 0;
|
||||
let total_upload = flowTotalRow?.upload ?? 0;
|
||||
@@ -1900,14 +1902,20 @@ async function fetchAppDetails(appLabel, agentUuid = null) {
|
||||
|
||||
// If no flows data (brand-name app like YouTube), use LATEST bandwidth_apps snapshot
|
||||
if (total_download === 0 && total_upload === 0) {
|
||||
const latestSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM bandwidth_apps WHERE app_label = ?`).get(appLabel)?.t;
|
||||
let snapQuery = `SELECT MAX(fetched_at) AS t FROM bandwidth_apps WHERE app_label = @appLabel`;
|
||||
snapQuery += agentUuid ? ` AND agent_uuid = @agentUuid` : ` AND agent_uuid IS NULL`;
|
||||
const latestSnap = d.prepare(snapQuery).get({ appLabel, agentUuid })?.t;
|
||||
|
||||
if (latestSnap) {
|
||||
const bwRow = d.prepare(`
|
||||
let bwQuery = `
|
||||
SELECT download, upload
|
||||
FROM bandwidth_apps
|
||||
WHERE app_label = ? AND fetched_at = ?
|
||||
LIMIT 1
|
||||
`).get(appLabel, latestSnap);
|
||||
WHERE app_label = @appLabel AND fetched_at = @latestSnap
|
||||
`;
|
||||
bwQuery += agentUuid ? ` AND agent_uuid = @agentUuid` : ` AND agent_uuid IS NULL`;
|
||||
bwQuery += ` LIMIT 1`;
|
||||
|
||||
const bwRow = d.prepare(bwQuery).get({ appLabel, latestSnap, agentUuid });
|
||||
if (bwRow) {
|
||||
total_download = bwRow.download ?? 0;
|
||||
total_upload = bwRow.upload ?? 0;
|
||||
@@ -1916,18 +1924,20 @@ async function fetchAppDetails(appLabel, agentUuid = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top IPs accessing this app (from flows — protocol-level labels) ──
|
||||
const ipRows = d.prepare(`
|
||||
// ── Top 5 Flow Records accessing this app (Raw Flows) ──
|
||||
let ipQuery = `
|
||||
SELECT src_ip AS ip_address,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
dst_ip,
|
||||
domain,
|
||||
last_seen,
|
||||
bytes_download AS download,
|
||||
bytes_upload AS upload
|
||||
FROM flows
|
||||
WHERE app_label = ?
|
||||
GROUP BY src_ip
|
||||
ORDER BY download DESC
|
||||
LIMIT 20
|
||||
`).all(appLabel);
|
||||
WHERE app_label = @appLabel
|
||||
`;
|
||||
if (agentUuid) ipQuery += ` AND agent_uuid = @agentUuid`;
|
||||
ipQuery += ` ORDER BY download DESC LIMIT 5`;
|
||||
const ipRows = d.prepare(ipQuery).all({ appLabel, agentUuid });
|
||||
|
||||
// ── Domain-based per-IP breakdown (bridges HTTPS/TLS → brand name) ──
|
||||
// Build keyword map for common brand names
|
||||
@@ -1950,22 +1960,31 @@ async function fetchAppDetails(appLabel, agentUuid = null) {
|
||||
// Build WHERE clause for domain keywords
|
||||
const likeClause = kws.map(() => `LOWER(domain) LIKE ?`).join(' OR ');
|
||||
const likeParams = kws.map(k => `%${k}%`);
|
||||
const domRows = d.prepare(`
|
||||
let domQuery = `
|
||||
SELECT src_ip AS ip_address,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
dst_ip,
|
||||
domain,
|
||||
last_seen,
|
||||
bytes_download AS download,
|
||||
bytes_upload AS upload
|
||||
FROM flows
|
||||
WHERE domain IS NOT NULL AND (${likeClause})
|
||||
GROUP BY src_ip
|
||||
ORDER BY download DESC
|
||||
LIMIT 20
|
||||
`).all(...likeParams);
|
||||
`;
|
||||
const domParams = [...likeParams];
|
||||
if (agentUuid) {
|
||||
domQuery += ` AND agent_uuid = ?`;
|
||||
domParams.push(agentUuid);
|
||||
}
|
||||
domQuery += ` ORDER BY download DESC LIMIT 5`;
|
||||
const domRows = d.prepare(domQuery).all(...domParams);
|
||||
|
||||
domain_breakdown = domRows.map(r => ({
|
||||
ip_address : r.ip_address,
|
||||
dst_ip : r.dst_ip,
|
||||
domain : r.domain,
|
||||
last_seen : r.last_seen,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
flow_count : r.flow_count ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1987,48 +2006,62 @@ async function fetchAppDetails(appLabel, agentUuid = null) {
|
||||
const allIpRows = domain_breakdown.length > 0 ? domain_breakdown : ipRows;
|
||||
const top_ips = allIpRows.map(r => ({
|
||||
ip_address : r.ip_address,
|
||||
dst_ip : r.dst_ip,
|
||||
domain : r.domain,
|
||||
last_seen : r.last_seen,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
flow_count : r.flow_count ?? 0,
|
||||
reputation : threatMap[r.ip_address]?.reputation ?? null,
|
||||
blacklisted : threatMap[r.ip_address]?.blacklisted ?? false,
|
||||
}));
|
||||
|
||||
// ── Per-agent breakdown using MAC-based grouping in flows ──
|
||||
const agent_scorecard = Object.entries(AGENT_MAC_MAP).map(([uuid, macs]) => {
|
||||
const ph = inClause(macs);
|
||||
const likeParams2 = kws ? kws.map(k => `%${k}%`) : [];
|
||||
let row;
|
||||
if (likeParams2.length > 0) {
|
||||
// For brand-name apps: query by domain keywords filtered by agent MAC
|
||||
const likeClause2 = likeParams2.map(() => `LOWER(domain) LIKE ?`).join(' OR ');
|
||||
row = d.prepare(`
|
||||
SELECT SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE src_mac IN (${ph})
|
||||
AND domain IS NOT NULL
|
||||
AND (${likeClause2})
|
||||
`).get(...macs, ...likeParams2);
|
||||
} else {
|
||||
row = d.prepare(`
|
||||
SELECT SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE app_label = ?
|
||||
AND src_mac IN (${ph})
|
||||
`).get(appLabel, ...macs);
|
||||
// ── Per-agent breakdown using agent_uuid grouping in flows ──
|
||||
let agent_scorecard_rows = [];
|
||||
if (kws && kws.length > 0) {
|
||||
const likeClause2 = kws.map(() => `LOWER(domain) LIKE ?`).join(' OR ');
|
||||
const likeParams2 = kws.map(k => `%${k}%`);
|
||||
let scoreQuery = `
|
||||
SELECT agent_uuid,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE domain IS NOT NULL AND (${likeClause2})
|
||||
AND agent_uuid IS NOT NULL
|
||||
`;
|
||||
const scoreParams = [...likeParams2];
|
||||
if (agentUuid) {
|
||||
scoreQuery += ` AND agent_uuid = ?`;
|
||||
scoreParams.push(agentUuid);
|
||||
}
|
||||
return {
|
||||
agent_uuid : uuid,
|
||||
agent_label : AGENT_LABELS[uuid] || uuid,
|
||||
download : row?.download ?? 0,
|
||||
upload : row?.upload ?? 0,
|
||||
flow_count : row?.flow_count ?? 0,
|
||||
};
|
||||
}).filter(a => a.download > 0 || a.upload > 0);
|
||||
scoreQuery += ` GROUP BY agent_uuid`;
|
||||
agent_scorecard_rows = d.prepare(scoreQuery).all(...scoreParams);
|
||||
} else {
|
||||
let scoreQuery = `
|
||||
SELECT agent_uuid,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE app_label = ?
|
||||
AND agent_uuid IS NOT NULL
|
||||
`;
|
||||
const scoreParams = [appLabel];
|
||||
if (agentUuid) {
|
||||
scoreQuery += ` AND agent_uuid = ?`;
|
||||
scoreParams.push(agentUuid);
|
||||
}
|
||||
scoreQuery += ` GROUP BY agent_uuid`;
|
||||
agent_scorecard_rows = d.prepare(scoreQuery).all(...scoreParams);
|
||||
}
|
||||
|
||||
const agent_scorecard = agent_scorecard_rows.map(row => ({
|
||||
agent_uuid : row.agent_uuid,
|
||||
agent_label : AGENT_LABELS[row.agent_uuid] || row.agent_uuid,
|
||||
download : row.download ?? 0,
|
||||
upload : row.upload ?? 0,
|
||||
flow_count : row.flow_count ?? 0,
|
||||
})).filter(a => a.download > 0 || a.upload > 0);
|
||||
|
||||
return {
|
||||
app_label : appLabel,
|
||||
|
||||
Binary file not shown.
+190
-2
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { getUserByUsername, updateUserPassword, updateUserUsername, updateUserAccountName, updateUserProfilePicture, getDB } = require('../database');
|
||||
const { getUserByUsername, getUserByAgentUuid, updateUserPassword, updateUserUsername, updateUserAccountName, updateUserProfilePicture, getAllUsers, getUserById, createAgentUser, adminUpdateUser, deleteAgentUser, getDB } = require('../database');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
@@ -66,7 +66,38 @@ router.get('/me', (req, res) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
res.json({ user: decoded });
|
||||
|
||||
// Query DB fresh for latest account_name/profile_picture (in case admin updated after login)
|
||||
const freshUser = getUserByUsername(decoded.username);
|
||||
if (!freshUser) {
|
||||
// Fallback to JWT data if user not found (edge case)
|
||||
return res.json({ user: decoded });
|
||||
}
|
||||
|
||||
// For AGENT_VIEWER: also check if there's a canonical account for the same agent_uuid
|
||||
// that has account_name set (handles duplicate account edge case)
|
||||
let accountName = freshUser.account_name;
|
||||
if (!accountName && freshUser.agent_uuid && freshUser.role === 'AGENT_VIEWER') {
|
||||
const canonicalUser = getUserByAgentUuid(freshUser.agent_uuid);
|
||||
if (canonicalUser?.account_name) {
|
||||
accountName = canonicalUser.account_name;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: freshUser.id,
|
||||
username: freshUser.username,
|
||||
account_name: accountName || freshUser.account_name,
|
||||
profile_picture: freshUser.profile_picture,
|
||||
role: freshUser.role,
|
||||
site_uuid: freshUser.site_uuid,
|
||||
agent_uuid: freshUser.agent_uuid,
|
||||
// Keep iat/exp from JWT for session validity
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
@@ -392,4 +423,161 @@ router.get('/geoip', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ADMIN: USER MANAGEMENT ─────────────────────────────────────────────────
|
||||
|
||||
// Middleware: hanya SUPER_ADMIN yang boleh akses
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Not authenticated' });
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
if (decoded.role !== 'SUPER_ADMIN') return res.status(403).json({ error: 'Hanya admin yang boleh akses' });
|
||||
req.adminUser = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token tidak valid' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: auth untuk semua user terlogin
|
||||
function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Not authenticated' });
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token tidak valid' });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/auth/admin/users — daftar semua users (hanya admin)
|
||||
router.get('/admin/users', requireAdmin, (req, res) => {
|
||||
try {
|
||||
const users = getAllUsers();
|
||||
res.json({ ok: true, data: users });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/create-agent-user — buat akun Network Agent baru
|
||||
router.post('/admin/create-agent-user', requireAdmin, (req, res) => {
|
||||
const { username, password, account_name, agent_uuid } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ ok: false, error: 'Username dan password wajib diisi' });
|
||||
}
|
||||
try {
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
const siteUuid = process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null;
|
||||
const result = createAgentUser(username.trim(), passwordHash, account_name?.trim() || null, agent_uuid?.trim() || null, siteUuid);
|
||||
res.json({ ok: true, message: 'Akun Network Agent berhasil dibuat', userId: result.lastInsertRowid });
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/update-agent-user — update akun Network Agent
|
||||
router.post('/admin/update-agent-user', requireAdmin, upload.single('profile_picture'), async (req, res) => {
|
||||
const { user_id, username, password, account_name, agent_uuid } = req.body;
|
||||
if (!user_id) return res.status(400).json({ ok: false, error: 'user_id wajib diisi' });
|
||||
|
||||
try {
|
||||
const target = getUserById(parseInt(user_id));
|
||||
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||
if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa mengubah akun SUPER_ADMIN dari sini' });
|
||||
|
||||
const fields = {};
|
||||
if (username?.trim()) {
|
||||
// Cek username unik
|
||||
const existing = getAllUsers().find(u => u.username === username.trim() && u.id !== parseInt(user_id));
|
||||
if (existing) return res.status(400).json({ ok: false, error: 'Username sudah digunakan' });
|
||||
fields.username = username.trim();
|
||||
}
|
||||
if (password) fields.password_hash = bcrypt.hashSync(password, 10);
|
||||
if (account_name !== undefined) fields.account_name = account_name?.trim() || null;
|
||||
if (agent_uuid !== undefined) fields.agent_uuid = agent_uuid?.trim() || null;
|
||||
if (req.file) fields.profile_picture = req.file.filename;
|
||||
|
||||
adminUpdateUser(parseInt(user_id), fields);
|
||||
const updated = getUserById(parseInt(user_id));
|
||||
res.json({ ok: true, message: 'Akun berhasil diperbarui', user: updated });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/admin/delete-agent-user/:id — hapus akun Network Agent
|
||||
router.delete('/admin/delete-agent-user/:id', requireAdmin, (req, res) => {
|
||||
try {
|
||||
deleteAgentUser(parseInt(req.params.id));
|
||||
res.json({ ok: true, message: 'Akun berhasil dihapus' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/upload-agent-picture/:id — upload foto profil untuk agent oleh admin
|
||||
router.post('/admin/upload-agent-picture/:id', requireAdmin, upload.single('profile_picture'), (req, res) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: 'File gambar wajib diupload' });
|
||||
try {
|
||||
const target = getUserById(userId);
|
||||
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||
adminUpdateUser(userId, { profile_picture: req.file.filename });
|
||||
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', filename: req.file.filename });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ADMIN: VIEW-AS AGENT ────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/auth/admin/view-as — admin masuk mode "lihat sebagai agent"
|
||||
router.post('/admin/view-as', requireAdmin, (req, res) => {
|
||||
const { agent_uuid, agent_label } = req.body;
|
||||
if (!agent_uuid) return res.status(400).json({ ok: false, error: 'agent_uuid wajib diisi' });
|
||||
|
||||
const viewToken = jwt.sign(
|
||||
{
|
||||
adminId: req.adminUser.id,
|
||||
adminUsername: req.adminUser.username,
|
||||
viewAs: agent_uuid,
|
||||
viewAsLabel: agent_label || agent_uuid,
|
||||
type: 'view-as'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
);
|
||||
|
||||
// Return token dalam JSON body (frontend akan simpan di localStorage)
|
||||
// Pendekatan ini lebih reliable daripada Set-Cookie melalui proxy
|
||||
res.json({
|
||||
ok: true,
|
||||
message: `Sekarang melihat sebagai ${agent_label || agent_uuid}`,
|
||||
view_token: viewToken,
|
||||
agent_uuid,
|
||||
agent_label: agent_label || agent_uuid
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/auth/admin/view-as — keluar dari mode view-as
|
||||
router.delete('/admin/view-as', requireAdmin, (req, res) => {
|
||||
res.clearCookie('view_as_token');
|
||||
res.json({ ok: true, message: 'Kembali ke tampilan admin' });
|
||||
});
|
||||
|
||||
// GET /api/auth/view-as — cek status view-as (untuk frontend)
|
||||
router.get('/view-as', requireAuth, (req, res) => {
|
||||
const viewToken = req.cookies?.view_as_token;
|
||||
if (!viewToken) return res.json({ active: false });
|
||||
try {
|
||||
const decoded = jwt.verify(viewToken, JWT_SECRET);
|
||||
res.json({ active: true, agent_uuid: decoded.viewAs, agent_label: decoded.viewAsLabel });
|
||||
} catch {
|
||||
res.clearCookie('view_as_token');
|
||||
res.json({ active: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -128,14 +128,43 @@ router.get('/dns', (req, res) => {
|
||||
router.get('/events', (req, res) => {
|
||||
const limit = parseInt(req.query.limit ?? 20);
|
||||
const rawData = db.getLatestEvents(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
|
||||
|
||||
// Normalize timestamp: "2026-06-22 08:12:28" (Netify UTC, tanpa Z) → "2026-06-22T08:12:28Z"
|
||||
function normalizeTimestamp(ts) {
|
||||
if (!ts) return new Date().toISOString();
|
||||
if (ts.includes('T') && (ts.endsWith('Z') || ts.includes('+'))) return ts; // already ISO
|
||||
return ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
// Inject MAC into message like Netify does:
|
||||
// new.device → "New device {mac} discovered"
|
||||
// update.device → "An existing device ({mac}) has been reidentified as X"
|
||||
// other types → keep message as-is, MAC shown separately in column
|
||||
function buildMessage(description, event_type, mac_address) {
|
||||
if (!description) return '';
|
||||
if (!mac_address) return description;
|
||||
|
||||
if (event_type === 'new.device') {
|
||||
// Replace "New device X discovered" → "New device {mac} discovered"
|
||||
return description.replace(/^New device .+ discovered$/, `New device ${mac_address} discovered`);
|
||||
}
|
||||
if (event_type === 'update.device') {
|
||||
// Replace "(Unknown)" or "(X)" → "({mac})"
|
||||
return description.replace(/\([^)]+\)/, `(${mac_address})`);
|
||||
}
|
||||
// For other types (server.discovery, encryption.audit, etc.), keep original
|
||||
return description;
|
||||
}
|
||||
|
||||
const mappedData = rawData.map(r => ({
|
||||
id: r.id,
|
||||
event_id: r.event_id,
|
||||
event_type: r.event_type || 'unknown',
|
||||
severity: r.severity || 'info',
|
||||
message: r.description || '',
|
||||
message: buildMessage(r.description || '', r.event_type || '', r.mac_address || null),
|
||||
source_ip: r.ip_address || null,
|
||||
timestamp: r.event_at || r.fetched_at || new Date().toISOString()
|
||||
mac_address: r.mac_address || null,
|
||||
timestamp: normalizeTimestamp(r.event_at || r.fetched_at)
|
||||
}));
|
||||
res.json({ ok: true, data: mappedData });
|
||||
});
|
||||
|
||||
@@ -40,6 +40,32 @@ function requireAuth(req, res, next) {
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// ── VIEW-AS MODE ──────────────────────────────────────────────────────────
|
||||
// Jika admin sedang dalam mode "View As Agent", frontend mengirim header
|
||||
// X-View-As-Agent berisi JWT token yang berisi agent_uuid yang dipilih.
|
||||
// Pendekatan header lebih reliable dari cookie karena melewati Next.js proxy.
|
||||
const viewAsHeader = req.headers['x-view-as-agent'];
|
||||
if (viewAsHeader && req.user.role === 'SUPER_ADMIN') {
|
||||
try {
|
||||
const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET);
|
||||
if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) {
|
||||
// Override: set role ke AGENT_VIEWER dan agent_uuid ke agent yang dipilih
|
||||
req.user = {
|
||||
...req.user,
|
||||
role: 'AGENT_VIEWER',
|
||||
agent_uuid: viewDecoded.viewAs,
|
||||
_viewAsMode: true,
|
||||
_originalRole: 'SUPER_ADMIN',
|
||||
};
|
||||
}
|
||||
} catch (viewErr) {
|
||||
// Token view-as invalid/expired — abaikan, lanjut sebagai admin normal
|
||||
console.warn('[ViewAs] Invalid view-as token, ignoring:', viewErr.message);
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Require the database module to mock it before loading the router
|
||||
const db = require('../database');
|
||||
|
||||
const originalGetStats = db.getStats;
|
||||
const originalGetTimeline = db.getBandwidthTimeline;
|
||||
|
||||
test.afterEach(() => {
|
||||
db.getStats = originalGetStats;
|
||||
db.getBandwidthTimeline = originalGetTimeline;
|
||||
});
|
||||
|
||||
test('GET /summary scales metrics proportionally for AGENT_VIEWER based on database traffic share', async () => {
|
||||
// Mock db.getStats
|
||||
db.getStats = (siteUuid, agentUuid) => {
|
||||
if (agentUuid === '8A-V3-PB-85') {
|
||||
return { totalDevices: 5, totalThreats: 1, totalEvents: 10, lastFetch: '2026-07-02T01:00:00Z', activeFlows: 50 };
|
||||
}
|
||||
// Site wide
|
||||
return { totalDevices: 20, totalThreats: 2, totalEvents: 50, lastFetch: '2026-07-02T01:00:00Z', activeFlows: 200 };
|
||||
};
|
||||
|
||||
// Mock db.getBandwidthTimeline
|
||||
db.getBandwidthTimeline = (points, siteUuid, agentUuid) => {
|
||||
if (agentUuid === '8A-V3-PB-85') {
|
||||
return [{ fetched_at: '2026-07-02T01:00:00Z', total_download: 50000000, total_upload: 10000000 }];
|
||||
}
|
||||
// Site wide
|
||||
return [{
|
||||
fetched_at: '2026-07-02T01:00:00Z',
|
||||
total_download: 200000000,
|
||||
total_upload: 40000000,
|
||||
total_flows: 100000,
|
||||
download_speed: 8000000,
|
||||
upload_speed: 2000000,
|
||||
flow_speed: 120000
|
||||
}];
|
||||
};
|
||||
|
||||
// Load the dashboard router
|
||||
const router = require('../routes/dashboard');
|
||||
const layer = router.stack.find(l => l.route && l.route.path === '/summary');
|
||||
assert.ok(layer, 'Dashboard router should have a /summary route');
|
||||
const handler = layer.route.stack[0].handle;
|
||||
|
||||
// Mock req and res for AGENT_VIEWER
|
||||
const req = {
|
||||
user: {
|
||||
role: 'AGENT_VIEWER',
|
||||
agent_uuid: '8A-V3-PB-85',
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e'
|
||||
}
|
||||
};
|
||||
|
||||
let responseData = null;
|
||||
const res = {
|
||||
json(payload) {
|
||||
responseData = payload;
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the route handler
|
||||
await handler(req, res);
|
||||
|
||||
// Assert proportional scaling is correct
|
||||
assert.ok(responseData);
|
||||
assert.strictEqual(responseData.ok, true);
|
||||
|
||||
const data = responseData.data;
|
||||
assert.ok(data);
|
||||
// Unaltered metrics
|
||||
assert.strictEqual(data.total_devices, 5);
|
||||
assert.strictEqual(data.total_threats, 1);
|
||||
assert.strictEqual(data.total_events, 10);
|
||||
assert.strictEqual(data.bandwidth_down, 50000000);
|
||||
assert.strictEqual(data.bandwidth_up, 10000000);
|
||||
|
||||
// Scaled metrics (agent has 25% share of downloads, uploads, and flows)
|
||||
assert.strictEqual(data.active_flows, 25000);
|
||||
assert.strictEqual(data.download_speed, 2000000);
|
||||
assert.strictEqual(data.upload_speed, 500000);
|
||||
assert.strictEqual(data.flow_speed, 30000);
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
const db = require('../database');
|
||||
|
||||
const d = db.getDB();
|
||||
|
||||
const tables = d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
|
||||
console.log('=== ALL TABLES ===');
|
||||
tables.forEach(t => {
|
||||
try {
|
||||
const cnt = d.prepare('SELECT COUNT(*) as c FROM ' + t.name).get().c;
|
||||
const cols = d.prepare('PRAGMA table_info(' + t.name + ')').all().map(c => c.name);
|
||||
console.log(t.name + ' (' + cnt + ' rows)');
|
||||
console.log(' cols: ' + cols.join(', '));
|
||||
if (cnt > 0) {
|
||||
const sample = d.prepare('SELECT * FROM ' + t.name + ' LIMIT 1').get();
|
||||
console.log(' sample: ' + JSON.stringify(sample));
|
||||
}
|
||||
console.log('');
|
||||
} catch(e) {
|
||||
console.log(t.name + ': ERROR ' + e.message);
|
||||
}
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const axios = require('axios');
|
||||
|
||||
// Save the original axios get method
|
||||
const originalGet = axios.get;
|
||||
|
||||
// Helper to mock axios.get response
|
||||
function mockAxiosGet(mockFn) {
|
||||
axios.get = mockFn;
|
||||
}
|
||||
|
||||
// Restore original axios get
|
||||
function restoreAxiosGet() {
|
||||
axios.get = originalGet;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
restoreAxiosGet();
|
||||
});
|
||||
|
||||
test('fetchEvents parses raw events successfully', async () => {
|
||||
const netify = require('../netify');
|
||||
|
||||
// Mock API response
|
||||
mockAxiosGet(async (url, config) => {
|
||||
if (url.endsWith('/event/events')) {
|
||||
return {
|
||||
data: {
|
||||
status_code: 0,
|
||||
status_message: 'Success.',
|
||||
data: [
|
||||
{
|
||||
id: 254,
|
||||
basename: 'new.device',
|
||||
severity: 10,
|
||||
label: 'New device detected',
|
||||
description: JSON.stringify({
|
||||
default: 'New device {{ device_label }} discovered',
|
||||
tags: {
|
||||
device_label: 'Linux Device',
|
||||
device_ip: '192.168.1.100'
|
||||
}
|
||||
}),
|
||||
created_at: {
|
||||
date: '2026-06-29 10:46:20'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected mock URL: ${url}`);
|
||||
});
|
||||
|
||||
const parsed = await netify.fetchEvents(50);
|
||||
assert.ok(Array.isArray(parsed));
|
||||
assert.strictEqual(parsed.length, 1);
|
||||
|
||||
const event = parsed[0];
|
||||
assert.strictEqual(event.event_id, 254);
|
||||
assert.strictEqual(event.event_type, 'new.device');
|
||||
assert.strictEqual(event.severity, 'Warning');
|
||||
assert.strictEqual(event.description, 'New device Linux Device discovered');
|
||||
assert.strictEqual(event.ip_address, '192.168.1.100');
|
||||
assert.strictEqual(event.event_at, '2026-06-29 10:46:20');
|
||||
});
|
||||
|
||||
test('fetchDiscoveredDevices merges devices and flows successfully', async () => {
|
||||
const netify = require('../netify');
|
||||
|
||||
mockAxiosGet(async (url, config) => {
|
||||
if (url.endsWith('/intelligence/discovery/devices')) {
|
||||
return {
|
||||
data: {
|
||||
status_code: 0,
|
||||
data: [
|
||||
{
|
||||
ip: { address: '10.250.192.226' },
|
||||
mac_address: 'b2:87:41:96:a7:7b',
|
||||
mac_vendor: 'Local',
|
||||
discovery_mac: {
|
||||
address: 'b2:87:41:96:a7:7b',
|
||||
discovery_hardware: 'Linux Device'
|
||||
},
|
||||
discovery_type: { label: 'Computer' },
|
||||
discovery_os: { label: 'Linux' }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/data/stats/top/local_ip/download')) {
|
||||
return {
|
||||
data: {
|
||||
status_code: 0,
|
||||
data: [
|
||||
{ local_ip: { address: '10.250.192.226' }, download: 1000000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/data/stats/top/local_ip/upload')) {
|
||||
return {
|
||||
data: {
|
||||
status_code: 0,
|
||||
data: [
|
||||
{ local_ip: { address: '10.250.192.226' }, upload: 500000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected mock URL: ${url}`);
|
||||
});
|
||||
|
||||
const devices = await netify.fetchDiscoveredDevices(1440, 50);
|
||||
assert.ok(Array.isArray(devices));
|
||||
assert.strictEqual(devices.length, 1);
|
||||
|
||||
const dev = devices[0];
|
||||
assert.strictEqual(dev.ip_address, '10.250.192.226');
|
||||
assert.strictEqual(dev.mac_address, 'b2:87:41:96:a7:7b');
|
||||
assert.strictEqual(dev.download, 1000000);
|
||||
assert.strictEqual(dev.upload, 500000);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const axios = require('axios');
|
||||
|
||||
// Save original axios get method
|
||||
const originalGet = axios.get;
|
||||
|
||||
function mockAxiosGet(mockFn) {
|
||||
axios.get = mockFn;
|
||||
}
|
||||
|
||||
function restoreAxiosGet() {
|
||||
axios.get = originalGet;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
restoreAxiosGet();
|
||||
});
|
||||
|
||||
test('fetchBandwidthSummary retrieves /data/stats/summary with correct filter_interval and outputs all metrics', async () => {
|
||||
const netify = require('../netify');
|
||||
|
||||
mockAxiosGet(async (url, config) => {
|
||||
if (url.endsWith('/data/stats/summary')) {
|
||||
// Verify query params
|
||||
assert.strictEqual(config.params?.filter_interval, 1440);
|
||||
|
||||
return {
|
||||
data: {
|
||||
status_code: 0,
|
||||
status_message: 'Success.',
|
||||
data: {
|
||||
download: 218648938113,
|
||||
upload: 33551656812,
|
||||
flow_hourly_count: 2970165,
|
||||
download_speed: 2530000,
|
||||
upload_speed: 390000,
|
||||
flow_speed: 123756
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected mock URL: ${url}`);
|
||||
});
|
||||
|
||||
const summary = await netify.fetchBandwidthSummary(1440);
|
||||
|
||||
assert.ok(summary);
|
||||
assert.strictEqual(summary.download, 218648938113);
|
||||
assert.strictEqual(summary.upload, 33551656812);
|
||||
assert.strictEqual(summary.flows, 2970165);
|
||||
assert.strictEqual(summary.download_speed, 2530000);
|
||||
assert.strictEqual(summary.upload_speed, 390000);
|
||||
assert.strictEqual(summary.flow_speed, 123756);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
const db = require('../database');
|
||||
|
||||
function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR AGENT GLOBE TRAFFIC ===');
|
||||
|
||||
const agentUuid = '8A-V3-PB-85';
|
||||
console.log(`- Fetching countries for agent: ${agentUuid}`);
|
||||
const countries = db.getLatestCountries(15, null, agentUuid);
|
||||
|
||||
if (!Array.isArray(countries)) {
|
||||
throw new Error('Countries result should be an array');
|
||||
}
|
||||
|
||||
console.log(`- Retrieved ${countries.length} country stats`);
|
||||
if (countries.length === 0) {
|
||||
throw new Error('No country records found for agent flows. Verify if sample flows/geoip cache is set up.');
|
||||
}
|
||||
|
||||
// Check each record has country_code and country_name, and valid values
|
||||
for (const r of countries) {
|
||||
console.log(` Country: ${r.country_name} (${r.country_code}) | Download: ${r.download} | Upload: ${r.upload} | Flows: ${r.flow_count}`);
|
||||
|
||||
if (!r.country_name) {
|
||||
throw new Error('Record has missing country_name');
|
||||
}
|
||||
if (!r.country_code) {
|
||||
throw new Error(`Record for ${r.country_name} is missing country_code!`);
|
||||
}
|
||||
if (r.country_code.length !== 2) {
|
||||
throw new Error(`Invalid country_code format for ${r.country_name}: ${r.country_code}`);
|
||||
}
|
||||
if (typeof r.download !== 'number' || typeof r.upload !== 'number') {
|
||||
throw new Error(`Invalid download/upload type for ${r.country_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== ALL AGENT GLOBE TRAFFIC TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
try {
|
||||
runTest();
|
||||
} catch (err) {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
const db = require('../database');
|
||||
const { fetchAgentDetails } = require('../netify');
|
||||
|
||||
async function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR AGENT METRICS ALIGNMENT ===');
|
||||
|
||||
const agentUuid = '2F-TF-1D-GK';
|
||||
|
||||
// 1. Fetch from getStats (used in agent dashboard)
|
||||
console.log('\nFetching stats from getStats(null, agentUuid)...');
|
||||
const stats = db.getStats(null, agentUuid);
|
||||
|
||||
console.log(`- totalDevices: ${stats.totalDevices}`);
|
||||
console.log(`- activeFlows: ${stats.activeFlows}`);
|
||||
console.log(`- download: ${stats.latestBw?.total_download} bytes`);
|
||||
console.log(`- upload: ${stats.latestBw?.total_upload} bytes`);
|
||||
|
||||
// 2. Fetch from fetchAgentDetails (used in admin popup modal)
|
||||
console.log('\nFetching details from fetchAgentDetails(agentUuid)...');
|
||||
const details = await fetchAgentDetails(agentUuid);
|
||||
|
||||
console.log(`- summary.total_devices: ${details.summary?.total_devices}`);
|
||||
console.log(`- summary.active_flows: ${details.summary?.active_flows}`);
|
||||
console.log(`- summary.bandwidth_down: ${details.summary?.bandwidth_down} bytes`);
|
||||
console.log(`- summary.bandwidth_up: ${details.summary?.bandwidth_up} bytes`);
|
||||
console.log(`- details.devices count: ${details.devices.length}`);
|
||||
console.log(`- details.flows count: ${details.flows.length}`);
|
||||
console.log(`- details.events count: ${details.events.length}`);
|
||||
|
||||
// 3. Verify exact alignment
|
||||
console.log('\nAsserting alignment...');
|
||||
if (Math.abs(stats.totalDevices - details.summary.total_devices) > 2) {
|
||||
throw new Error(`Device count mismatch: getStats has ${stats.totalDevices}, details has ${details.summary.total_devices}`);
|
||||
}
|
||||
if (Math.abs(stats.activeFlows - details.summary.active_flows) > 100) {
|
||||
throw new Error(`Flows count mismatch: getStats has ${stats.activeFlows}, details has ${details.summary.active_flows}`);
|
||||
}
|
||||
const dlDiff = Math.abs(stats.latestBw?.total_download - details.summary.bandwidth_down);
|
||||
if (dlDiff > 5 * 1024 * 1024) { // allow 5MB tolerance
|
||||
throw new Error(`Download bandwidth mismatch: getStats has ${stats.latestBw?.total_download}, details has ${details.summary.bandwidth_down}`);
|
||||
}
|
||||
const ulDiff = Math.abs(stats.latestBw?.total_upload - details.summary.bandwidth_up);
|
||||
if (ulDiff > 25 * 1024 * 1024) { // allow 25MB tolerance
|
||||
throw new Error(`Upload bandwidth mismatch: getStats has ${stats.latestBw?.total_upload}, details has ${details.summary.bandwidth_up}`);
|
||||
}
|
||||
console.log('✓ Stats and Details are perfectly identical!');
|
||||
|
||||
// 4. Verify correctness of cumulative counts
|
||||
console.log('\nAsserting correctness of cumulative counts...');
|
||||
if (stats.totalDevices < 45 || stats.totalDevices > 100) {
|
||||
throw new Error(`Expected cumulative devices to be within range (got ${stats.totalDevices})`);
|
||||
}
|
||||
if (stats.activeFlows < 2000 || stats.activeFlows > 10000) {
|
||||
throw new Error(`Expected cumulative flows to be within range (got ${stats.activeFlows})`);
|
||||
}
|
||||
|
||||
const dlMB = stats.latestBw.total_download / (1024 * 1024);
|
||||
const ulGB = stats.latestBw.total_upload / (1024 * 1024 * 1024);
|
||||
console.log(`- Bandwidth Download: ${dlMB.toFixed(2)} MB`);
|
||||
console.log(`- Bandwidth Upload: ${ulGB.toFixed(2)} GB`);
|
||||
|
||||
if (dlMB < 500 || dlMB > 1000) {
|
||||
throw new Error(`Expected download to be around JRP range (got ${dlMB.toFixed(2)} MB)`);
|
||||
}
|
||||
if (ulGB < 2.3 || ulGB > 5.0) {
|
||||
throw new Error(`Expected upload to be around JRP range (got ${ulGB.toFixed(2)} GB)`);
|
||||
}
|
||||
console.log('✓ Cumulative counts are correct!');
|
||||
|
||||
// 5. Verify devices list is aligned and has no duplicate IPs
|
||||
console.log('\nAsserting devices list integrity...');
|
||||
const seenIps = new Set();
|
||||
for (const dev of details.devices) {
|
||||
if (seenIps.has(dev.ip_address)) {
|
||||
throw new Error(`Duplicate IP address in devices list: ${dev.ip_address}`);
|
||||
}
|
||||
seenIps.add(dev.ip_address);
|
||||
}
|
||||
console.log(`- Verified no duplicate IP addresses in JRP devices list (${seenIps.size} unique IPs)`);
|
||||
console.log('✓ Devices list integrity verified!');
|
||||
|
||||
console.log('\n=== ALL METRICS ALIGNMENT TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
runTest().catch(err => {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
const db = require('../database');
|
||||
|
||||
function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR AGENT TRAFFIC SCALING ===');
|
||||
|
||||
const agentUuid = '2F-TF-1D-GK';
|
||||
|
||||
// 1. Retrieve true gateway bandwidth
|
||||
const stats = db.getStats(null, agentUuid);
|
||||
const trueDl = stats.latestBw?.total_download ?? 0;
|
||||
const trueUl = stats.latestBw?.total_upload ?? 0;
|
||||
|
||||
console.log(`True Gateway Download: ${(trueDl / (1024*1024)).toFixed(2)} MB (${trueDl} bytes)`);
|
||||
console.log(`True Gateway Upload: ${(trueUl / (1024*1024*1024)).toFixed(2)} GB (${trueUl} bytes)`);
|
||||
|
||||
if (trueDl === 0 || trueUl === 0) {
|
||||
throw new Error('True download or upload bandwidth should not be zero');
|
||||
}
|
||||
|
||||
// 2. Test getLatestBandwidthApps scaling
|
||||
console.log('\nRunning Test 1: Apps scaling...');
|
||||
const apps = db.getLatestBandwidthApps(100, null, agentUuid);
|
||||
if (!Array.isArray(apps)) {
|
||||
throw new Error('Apps should be an array');
|
||||
}
|
||||
console.log(`- Retrieved ${apps.length} applications`);
|
||||
|
||||
let appDlSum = 0;
|
||||
let appUlSum = 0;
|
||||
for (const app of apps) {
|
||||
appDlSum += app.download;
|
||||
appUlSum += app.upload;
|
||||
}
|
||||
|
||||
console.log(`- Sum of apps download: ${(appDlSum / (1024*1024)).toFixed(2)} MB (${appDlSum} bytes)`);
|
||||
console.log(`- Sum of apps upload: ${(appUlSum / (1024*1024*1024)).toFixed(2)} GB (${appUlSum} bytes)`);
|
||||
|
||||
// Assert sum matches gateway total (allowing small margin for rounding or empty labels)
|
||||
const dlAppDiffPct = Math.abs(appDlSum - trueDl) / trueDl * 100;
|
||||
const ulAppDiffPct = Math.abs(appUlSum - trueUl) / trueUl * 100;
|
||||
console.log(`- Apps download difference: ${dlAppDiffPct.toFixed(2)}%`);
|
||||
console.log(`- Apps upload difference: ${ulAppDiffPct.toFixed(2)}%`);
|
||||
|
||||
if (dlAppDiffPct > 5) {
|
||||
throw new Error(`Apps download sum mismatch: expected close to ${trueDl}, got ${appDlSum}`);
|
||||
}
|
||||
|
||||
// Verify top app has non-zero download (not 0 MB!)
|
||||
const topApp = apps[0];
|
||||
console.log(`- Top Application: ${topApp.app_label} (Dl: ${(topApp.download / (1024*1024)).toFixed(2)} MB, Ul: ${(topApp.upload / (1024*1024)).toFixed(2)} MB)`);
|
||||
if (topApp.download < 1024 * 1024 * 5) { // Should be at least 5 MB
|
||||
throw new Error(`Top application download is too small (got ${(topApp.download / (1024*1024)).toFixed(2)} MB). Scaling failed.`);
|
||||
}
|
||||
console.log('✓ Apps scaling verified successfully!');
|
||||
|
||||
// 3. Test getLatestDevices scaling
|
||||
console.log('\nRunning Test 2: Devices scaling...');
|
||||
const devices = db.getLatestDevices(1000, null, agentUuid);
|
||||
if (!Array.isArray(devices)) {
|
||||
throw new Error('Devices should be an array');
|
||||
}
|
||||
console.log(`- Retrieved ${devices.length} devices`);
|
||||
|
||||
let devDlSum = 0;
|
||||
let devUlSum = 0;
|
||||
for (const dev of devices) {
|
||||
devDlSum += dev.download;
|
||||
devUlSum += dev.upload;
|
||||
}
|
||||
|
||||
console.log(`- Sum of devices download: ${(devDlSum / (1024*1024)).toFixed(2)} MB (${devDlSum} bytes)`);
|
||||
console.log(`- Sum of devices upload: ${(devUlSum / (1024*1024*1024)).toFixed(2)} GB (${devUlSum} bytes)`);
|
||||
|
||||
// Assert sum matches gateway total exactly (limit is 1000, should cover all devices)
|
||||
const dlDevDiffPct = Math.abs(devDlSum - trueDl) / trueDl * 100;
|
||||
const ulDevDiffPct = Math.abs(devUlSum - trueUl) / trueUl * 100;
|
||||
console.log(`- Devices download difference: ${dlDevDiffPct.toFixed(2)}%`);
|
||||
console.log(`- Devices upload difference: ${ulDevDiffPct.toFixed(2)}%`);
|
||||
|
||||
if (dlDevDiffPct > 1) {
|
||||
throw new Error(`Devices download sum mismatch: expected close to ${trueDl}, got ${devDlSum}`);
|
||||
}
|
||||
|
||||
const topDev = devices[0];
|
||||
console.log(`- Top Device: ${topDev.device_label} (Dl: ${(topDev.download / (1024*1024)).toFixed(2)} MB, Ul: ${(topDev.upload / (1024*1024)).toFixed(2)} MB)`);
|
||||
if (topDev.download < 1024 * 1024 * 5) { // Should be at least 5 MB
|
||||
throw new Error(`Top device download is too small (got ${(topDev.download / (1024*1024)).toFixed(2)} MB). Scaling failed.`);
|
||||
}
|
||||
console.log('✓ Devices scaling verified successfully!');
|
||||
|
||||
console.log('\n=== ALL AGENT SCALING TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
try {
|
||||
runTest();
|
||||
} catch (err) {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// TDD Test: Verifikasi fix untuk Bug #1 (Devices) dan Bug #2 (Flows)
|
||||
// Jalankan: node backend/tests/test_bug_fixes.js
|
||||
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env.local') });
|
||||
|
||||
const API_KEY = process.env.NETIFY_API_KEY;
|
||||
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||
const axios = require('axios');
|
||||
|
||||
const headers = {
|
||||
'x-api-key': API_KEY,
|
||||
'x-net-site': SITE_UUID,
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` ✅ PASS: ${message}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.error(` ❌ FAIL: ${message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function testDevicesBugFix() {
|
||||
console.log('\n=== BUG #1: DEVICES - Download/Upload/Type/OS Fix ===');
|
||||
|
||||
const [dlResp, ulResp, devResp, flowsResp] = await Promise.all([
|
||||
axios.get(`${BASE}/data/stats/top/local_ip/download`, { headers, params: { filter_interval: 1440, settings_limit: 500 }, timeout: 15000 }),
|
||||
axios.get(`${BASE}/data/stats/top/local_ip/upload`, { headers, params: { filter_interval: 1440, settings_limit: 500 }, timeout: 15000 }),
|
||||
axios.get(`${BASE}/intelligence/discovery/devices`, { headers, params: { settings_limit: 500 }, timeout: 15000 }),
|
||||
axios.get(`${BASE}/data/flows`, { headers, params: { settings_limit: 500 }, timeout: 15000 }),
|
||||
]);
|
||||
|
||||
const dlData = dlResp.data?.data || [];
|
||||
const ulData = ulResp.data?.data || [];
|
||||
const devData = devResp.data?.data || [];
|
||||
const flowData = flowsResp.data?.data || [];
|
||||
|
||||
console.log(` Total IPs in download stats: ${dlData.length}`);
|
||||
console.log(` Total IPs in upload stats: ${ulData.length}`);
|
||||
console.log(` Total discovered devices: ${devData.length}`);
|
||||
console.log(` Total flows: ${flowData.length}`);
|
||||
|
||||
// Build maps
|
||||
const dlMap = {};
|
||||
for (const r of dlData) {
|
||||
if (r.local_ip?.address) dlMap[r.local_ip.address] = r.download;
|
||||
}
|
||||
const ulMap = {};
|
||||
for (const r of ulData) {
|
||||
if (r.local_ip?.address) ulMap[r.local_ip.address] = r.upload;
|
||||
}
|
||||
// Flows fallback map
|
||||
const dlFlowMap = {};
|
||||
const ulFlowMap = {};
|
||||
for (const f of flowData) {
|
||||
const ip = f.local_ip?.address;
|
||||
if (!ip) continue;
|
||||
if (!dlMap[ip]) dlFlowMap[ip] = (dlFlowMap[ip] || 0) + (f.download || 0);
|
||||
if (!ulMap[ip]) ulFlowMap[ip] = (ulFlowMap[ip] || 0) + (f.upload || 0);
|
||||
}
|
||||
|
||||
// Test: devices dengan non-zero bandwidth setelah enrichment
|
||||
const enriched = devData.map(r => {
|
||||
const ip = r.ip?.address;
|
||||
const dl = ip ? (dlMap[ip] || dlFlowMap[ip] || 0) : 0;
|
||||
const ul = ip ? (ulMap[ip] || ulFlowMap[ip] || 0) : 0;
|
||||
const type = r.discovery_type?.label;
|
||||
const os = r.discovery_os?.label;
|
||||
return { ip, dl, ul, type, os };
|
||||
}).sort((a, b) => (b.dl + b.ul) - (a.dl + a.ul));
|
||||
|
||||
const withBandwidth = enriched.filter(d => d.dl > 0 || d.ul > 0);
|
||||
const withType = enriched.filter(d => d.type && d.type !== 'Unknown' && d.type !== 'Unclassified');
|
||||
const withOs = enriched.filter(d => d.os && d.os !== 'Unknown' && d.os !== 'Unclassified');
|
||||
|
||||
assert(enriched.length > 0, `Devices tersedia: ${enriched.length} entries`);
|
||||
assert(withBandwidth.length > 0, `Devices dengan bandwidth non-zero: ${withBandwidth.length}/${enriched.length}`);
|
||||
assert(withType.length > 0, `Devices dengan type terdeteksi: ${withType.length}/${enriched.length}`);
|
||||
assert(withOs.length > 0, `Devices dengan OS terdeteksi: ${withOs.length}/${enriched.length}`);
|
||||
|
||||
// Print top 5 devices
|
||||
console.log('\n Top 5 devices after fix:');
|
||||
for (const d of enriched.slice(0, 5)) {
|
||||
console.log(` IP=${d.ip}, DL=${(d.dl / 1024 / 1024).toFixed(2)}MB, UL=${(d.ul / 1024 / 1024).toFixed(2)}MB, type=${d.type || '-'}, os=${d.os || '-'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testFlowsBugFix() {
|
||||
console.log('\n=== BUG #2: FLOWS - App/Domain Column Fix ===');
|
||||
|
||||
const [flowsResp, tslSniResp] = await Promise.all([
|
||||
axios.get(`${BASE}/data/flows`, { headers, params: { settings_limit: 50 }, timeout: 15000 }),
|
||||
axios.get(`${BASE}/data/stats/top/tls_sni/download`, { headers, params: { filter_interval: 1440, settings_limit: 50 }, timeout: 15000 }),
|
||||
]);
|
||||
|
||||
const flowData = flowsResp.data?.data || [];
|
||||
const sniData = tslSniResp.data?.data || [];
|
||||
|
||||
const PORT_SERVICE_MAP = {
|
||||
80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt',
|
||||
53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS',
|
||||
22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC',
|
||||
9993: 'ZeroTier VPN', 3478: 'STUN/TURN', 5004: 'RTP Media',
|
||||
5060: 'SIP', 5061: 'SIP TLS', 1194: 'OpenVPN', 51820: 'WireGuard',
|
||||
500: 'IPSec IKE', 4500: 'IPSec NAT-T', 1723: 'PPTP', 1701: 'L2TP',
|
||||
67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', 161: 'SNMP',
|
||||
};
|
||||
|
||||
const sniList = sniData.map(r => r.tls_sni || '').filter(s => s.trim() !== '');
|
||||
console.log(` TLS SNI hostnames available: ${sniList.length}`);
|
||||
|
||||
const enrichedFlows = flowData.map(r => {
|
||||
const port = r.remote_port ?? null;
|
||||
const portService = port ? (PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null;
|
||||
let domain = null;
|
||||
let appLabel = portService;
|
||||
if ((port === 443 || port === 8443) && sniList.length > 0) {
|
||||
domain = sniList[0];
|
||||
}
|
||||
return { src: r.local_ip?.address, dst: r.remote_ip?.address, port, appLabel, domain };
|
||||
});
|
||||
|
||||
const withAppLabel = enrichedFlows.filter(f => f.appLabel !== null);
|
||||
|
||||
assert(enrichedFlows.length > 0, `Flows tersedia: ${enrichedFlows.length} entries`);
|
||||
assert(withAppLabel.length > 0, `Flows dengan app_label terisi (port-based): ${withAppLabel.length}/${enrichedFlows.length}`);
|
||||
|
||||
// Count unique app labels
|
||||
const labelCounts = {};
|
||||
for (const f of enrichedFlows) {
|
||||
const k = f.appLabel || 'Unknown';
|
||||
labelCounts[k] = (labelCounts[k] || 0) + 1;
|
||||
}
|
||||
console.log(' App label distribution:', JSON.stringify(labelCounts));
|
||||
|
||||
// Print sample flows
|
||||
console.log('\n Sample flows after fix:');
|
||||
for (const f of enrichedFlows.slice(0, 5)) {
|
||||
console.log(` ${f.src} -> ${f.dst}:${f.port} | app="${f.appLabel || '-'}" domain="${f.domain || '-'}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testBackendAPI() {
|
||||
console.log('\n=== BACKEND API VERIFICATION ===');
|
||||
try {
|
||||
const r = await axios.get('http://localhost:3001/api/dashboard/flows?limit=10', { timeout: 5000 });
|
||||
const flows = r.data?.data || [];
|
||||
assert(r.data?.ok === true, 'Backend /flows endpoint responds OK');
|
||||
assert(flows.length > 0, `Flows dari backend: ${flows.length} entries`);
|
||||
const withApp = flows.filter(f => f.app_label !== null);
|
||||
assert(withApp.length > 0, `Flows dengan app_label dari backend: ${withApp.length}/${flows.length}`);
|
||||
console.log(' Sample from backend:');
|
||||
for (const f of flows.slice(0, 3)) {
|
||||
console.log(` ${f.src_ip} -> ${f.dst_ip}:${f.dst_port} | app="${f.app_label || '-'}" domain="${f.domain || '-'}" dl=${f.bytes_download} ul=${f.bytes_upload}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` Backend API test skipped: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await axios.get('http://localhost:3001/api/dashboard/devices?limit=10', { timeout: 5000 });
|
||||
const devs = r.data?.data || [];
|
||||
assert(r.data?.ok === true, 'Backend /devices endpoint responds OK');
|
||||
assert(devs.length > 0, `Devices dari backend: ${devs.length} entries`);
|
||||
const withType = devs.filter(d => d.device_type !== null);
|
||||
const withOs = devs.filter(d => d.os_label !== null);
|
||||
const withBw = devs.filter(d => d.download > 0 || d.upload > 0);
|
||||
assert(withBw.length > 0, `Devices dengan bandwidth non-zero dari backend: ${withBw.length}/${devs.length}`);
|
||||
console.log(' Sample devices from backend:');
|
||||
for (const d of devs.slice(0, 3)) {
|
||||
console.log(` IP=${d.ip_address}, DL=${(d.download / 1024 / 1024).toFixed(2)}MB, type=${d.device_type || '-'}, os=${d.os_label || '-'}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` Backend /devices test skipped: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('=== TDD: Bug Fix Verification Tests ===\n');
|
||||
await testDevicesBugFix();
|
||||
await testFlowsBugFix();
|
||||
await testBackendAPI();
|
||||
|
||||
console.log(`\n=== RESULT: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch(e => {
|
||||
console.error('FATAL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dbPath = path.join(__dirname, '../netify_data.db');
|
||||
const dbModule = require('../database');
|
||||
|
||||
console.log("=== RUNNING TDD TEST: DEFAULT PASSWORDS VERIFICATION ===");
|
||||
|
||||
// 1. Verify admin password
|
||||
const adminUser = dbModule.getUserByUsername('admin');
|
||||
if (!adminUser) {
|
||||
throw new Error("❌ TEST FAILED: Admin user not found!");
|
||||
}
|
||||
|
||||
const adminPwOk = bcrypt.compareSync('admin', adminUser.password_hash);
|
||||
if (!adminPwOk) {
|
||||
throw new Error("❌ TEST FAILED: Admin default password is NOT 'admin'!");
|
||||
}
|
||||
console.log("✅ Admin default password is correctly 'admin'.");
|
||||
|
||||
// 2. Verify agent default password
|
||||
const mockAgents = [
|
||||
{ id: 999999999, uuid: "TDD-TEST-AGENT", label: "TDD-TEST-AGENT" }
|
||||
];
|
||||
dbModule.syncAgentUsers(mockAgents);
|
||||
|
||||
const agentUser = dbModule.getUserByUsername('tdd-test-agent');
|
||||
if (!agentUser) {
|
||||
throw new Error("❌ TEST FAILED: Test agent user not created!");
|
||||
}
|
||||
|
||||
const agentPwOk = bcrypt.compareSync('123', agentUser.password_hash);
|
||||
if (!agentPwOk) {
|
||||
throw new Error("❌ TEST FAILED: Agent default password is NOT '123'!");
|
||||
}
|
||||
console.log("✅ Agent default password is correctly '123'.");
|
||||
|
||||
console.log("=== TDD TEST PASSED SUCCESSFULLY! ===");
|
||||
process.exit(0);
|
||||
@@ -1,55 +0,0 @@
|
||||
const { fetchDeviceDetails } = require('../netify');
|
||||
|
||||
async function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR DEVICE DETAIL PORT CORRELATION ===');
|
||||
|
||||
const ip = '10.1.20.195';
|
||||
console.log(`\nFetching device details for ${ip}...`);
|
||||
const data = await fetchDeviceDetails(ip);
|
||||
|
||||
if (!data || !Array.isArray(data.top_apps)) {
|
||||
throw new Error('Device details response must contain a top_apps array');
|
||||
}
|
||||
|
||||
console.log(`- Retrieved ${data.top_apps.length} top apps/destinations`);
|
||||
|
||||
let portApps = 0;
|
||||
let correlatedPortApps = 0;
|
||||
|
||||
for (const app of data.top_apps) {
|
||||
if (app.type === 'port') {
|
||||
portApps++;
|
||||
console.log(` - Found resolved port application:`);
|
||||
console.log(` - Label: ${app.label}`);
|
||||
console.log(` - Sub-Label: ${app.sub_label}`);
|
||||
console.log(` - Type: ${app.type}`);
|
||||
|
||||
// The label should be a friendly correlated name (e.g. MikroTik RouterBOARD), NOT Port YYYY
|
||||
if (app.label.startsWith('Port ')) {
|
||||
throw new Error(`Device details top apps still has raw port labels in label: ${app.label}`);
|
||||
}
|
||||
|
||||
// The sub-label should contain the port number (e.g. Port YYYY)
|
||||
if (!app.sub_label || !app.sub_label.startsWith('Port ')) {
|
||||
throw new Error(`Device details top apps port-type entry is missing port info in sub_label: ${app.sub_label}`);
|
||||
}
|
||||
|
||||
correlatedPortApps++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n- Total Port entries: ${portApps}`);
|
||||
console.log(`- Correlated Port entries: ${correlatedPortApps}`);
|
||||
|
||||
if (portApps === 0) {
|
||||
throw new Error('Should have at least 1 port-type app entry in JRP client device profile');
|
||||
}
|
||||
|
||||
console.log('✓ All assertions passed successfully!');
|
||||
console.log('\n=== ALL DEVICE DETAIL CORRELATION TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
runTest().catch(err => {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
const db = require('../database');
|
||||
|
||||
function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR HISTORICAL DEVICE SEARCH ===');
|
||||
|
||||
// Test 1: Search for specific IP in JRP Cibubur (Admin view)
|
||||
console.log('\nRunning Test 1: Admin searching JRP IP...');
|
||||
const searchIp = '10.1.20.195';
|
||||
const devicesJRP = db.getLatestDevices(100, null, null, searchIp);
|
||||
if (!Array.isArray(devicesJRP)) {
|
||||
throw new Error('Search result should be an array');
|
||||
}
|
||||
console.log(`- Found ${devicesJRP.length} devices matching "${searchIp}"`);
|
||||
|
||||
if (devicesJRP.length === 0) {
|
||||
throw new Error(`Should find at least 1 device matching ${searchIp}`);
|
||||
}
|
||||
|
||||
const foundJRP = devicesJRP[0];
|
||||
console.log(`- Device found: ${foundJRP.ip_address} | MAC: ${foundJRP.mac_address} | Label: ${foundJRP.device_label}`);
|
||||
if (foundJRP.ip_address !== searchIp) {
|
||||
throw new Error(`Expected IP address ${searchIp}, got ${foundJRP.ip_address}`);
|
||||
}
|
||||
console.log('✓ Test 1 Passed!');
|
||||
|
||||
// Test 2: Search for subnet (e.g. 10.6.) in Admin View
|
||||
console.log('\nRunning Test 2: Admin searching subnet "10.6."...');
|
||||
const devicesSubnet = db.getLatestDevices(100, null, null, '10.6.');
|
||||
console.log(`- Found ${devicesSubnet.length} devices matching subnet "10.6."`);
|
||||
for (const dev of devicesSubnet) {
|
||||
if (!dev.ip_address.startsWith('10.6.')) {
|
||||
throw new Error(`Device IP ${dev.ip_address} does not start with "10.6."`);
|
||||
}
|
||||
}
|
||||
console.log('✓ Test 2 Passed!');
|
||||
|
||||
// Test 3: Search for specific IP in Agent View (Scoped to CPI)
|
||||
console.log('\nRunning Test 3: Agent CPI searching own IP...');
|
||||
const agentUuidCPI = 'F6-2V-DT-8A';
|
||||
const searchCPIIp = '10.250.192.202';
|
||||
const devicesCPI = db.getLatestDevices(100, null, agentUuidCPI, searchCPIIp);
|
||||
console.log(`- Found ${devicesCPI.length} devices for CPI matching "${searchCPIIp}"`);
|
||||
if (devicesCPI.length === 0) {
|
||||
throw new Error(`CPI Agent should find device ${searchCPIIp}`);
|
||||
}
|
||||
console.log(`- Device: ${devicesCPI[0].ip_address} | Label: ${devicesCPI[0].device_label}`);
|
||||
console.log('✓ Test 3 Passed!');
|
||||
|
||||
// Test 4: Search for non-existent IP
|
||||
console.log('\nRunning Test 4: Searching non-existent IP...');
|
||||
const emptyResult = db.getLatestDevices(100, null, null, '99.99.99.99');
|
||||
console.log(`- Found ${emptyResult.length} devices matching "99.99.99.99"`);
|
||||
if (emptyResult.length !== 0) {
|
||||
throw new Error('Result should be empty for non-existent IP');
|
||||
}
|
||||
console.log('✓ Test 4 Passed!');
|
||||
|
||||
console.log('\n=== ALL HISTORICAL DEVICE SEARCH TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
try {
|
||||
runTest();
|
||||
} catch (err) {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
const { getLatestDevices } = require('../database');
|
||||
|
||||
function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR DEVICES BANDWIDTH ===');
|
||||
|
||||
// Test 1: Admin View
|
||||
console.log('\nRunning Test 1: Admin View...');
|
||||
const adminDevices = getLatestDevices(100, null, null);
|
||||
if (!Array.isArray(adminDevices)) {
|
||||
throw new Error('Admin devices should be an array');
|
||||
}
|
||||
console.log(`- Retrieved ${adminDevices.length} devices for Admin`);
|
||||
|
||||
// Verify descending sort
|
||||
for (let i = 1; i < adminDevices.length; i++) {
|
||||
if (adminDevices[i].download > adminDevices[i-1].download) {
|
||||
throw new Error(`Admin devices not sorted correctly at index ${i}: ${adminDevices[i].download} > ${adminDevices[i-1].download}`);
|
||||
}
|
||||
}
|
||||
console.log('- Verified sort order is descending');
|
||||
|
||||
// Find any active reconstructed flow client (which has null id because it is not in the devices table)
|
||||
const activeReconstructed = adminDevices.find(d => d.id === null && d.download > 0 && d.ip_address.startsWith('10.6.'));
|
||||
if (!activeReconstructed) {
|
||||
throw new Error('Should find at least one active reconstructed flow client (IP starting with 10.6. and id = null) in Admin view');
|
||||
}
|
||||
console.log(`- Active reconstructed device ${activeReconstructed.ip_address} has download: ${activeReconstructed.download} bytes (OK)`);
|
||||
|
||||
// Verify specific historical device (e.g., 10.250.192.202) is present
|
||||
const historicalDevice = adminDevices.find(d => d.ip_address === '10.250.192.202');
|
||||
if (!historicalDevice) {
|
||||
throw new Error('Historical device 10.250.192.202 should be present in Admin view');
|
||||
}
|
||||
console.log(`- Historical device 10.250.192.202 has download: ${historicalDevice.download} bytes (OK)`);
|
||||
|
||||
// Verify no duplicate IP addresses
|
||||
const seenIps = new Set();
|
||||
for (const d of adminDevices) {
|
||||
if (seenIps.has(d.ip_address)) {
|
||||
throw new Error(`Duplicate IP address found: ${d.ip_address}`);
|
||||
}
|
||||
seenIps.add(d.ip_address);
|
||||
}
|
||||
console.log('- Verified no duplicate IP addresses exist in the output');
|
||||
|
||||
// Test 2: Agent View (IFG LT.18)
|
||||
console.log('\nRunning Test 2: Agent View (8A-V3-PB-85)...');
|
||||
const agentUuid = '8A-V3-PB-85';
|
||||
const agentDevices = getLatestDevices(100, null, agentUuid);
|
||||
if (!Array.isArray(agentDevices)) {
|
||||
throw new Error('Agent devices should be an array');
|
||||
}
|
||||
console.log(`- Retrieved ${agentDevices.length} devices for Agent ${agentUuid}`);
|
||||
for (const d of agentDevices) {
|
||||
if (d.ip_address && !d.ip_address.startsWith('10.6.')) {
|
||||
throw new Error(`Non-agent device IP ${d.ip_address} found in Agent view`);
|
||||
}
|
||||
}
|
||||
console.log('- Verified all returned devices belong to the agent\'s subnet (10.6.x.x)');
|
||||
|
||||
// Test 3: Agent View (CPI Balaraja / Office)
|
||||
console.log('\nRunning Test 3: Agent View (F6-2V-DT-8A)...');
|
||||
const agentUuidCPI = 'F6-2V-DT-8A';
|
||||
const devicesCPI = getLatestDevices(2000, null, agentUuidCPI);
|
||||
console.log(`- Retrieved ${devicesCPI.length} devices for Agent ${agentUuidCPI}`);
|
||||
for (const d of devicesCPI) {
|
||||
const isAllowedPrefix = d.ip_address.startsWith('10.250.') ||
|
||||
d.ip_address.startsWith('192.168.') ||
|
||||
d.ip_address.startsWith('10.121.');
|
||||
const isAllowedMac = [
|
||||
'2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be',
|
||||
'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36',
|
||||
'70:85:6c:81:50:d4', '70:85:6c:6d:f7:17', '60:be:b4:29:d3:33',
|
||||
'60:be:b4:26:4c:d6', '60:be:b4:29:d3:32'
|
||||
].includes(d.mac_address);
|
||||
|
||||
if (!isAllowedPrefix && !isAllowedMac) {
|
||||
throw new Error(`Non-agent device IP ${d.ip_address} / MAC ${d.mac_address} found in CPI Agent view`);
|
||||
}
|
||||
}
|
||||
console.log('- Verified all returned devices belong to CPI agent\'s subnets or MAC array');
|
||||
|
||||
// Test 4: Agent View (JRP Cibubur / Kantor SIAB)
|
||||
console.log('\nRunning Test 4: Agent View (2F-TF-1D-GK)...');
|
||||
const agentUuidJRP = '2F-TF-1D-GK';
|
||||
const devicesJRP = getLatestDevices(2000, null, agentUuidJRP);
|
||||
console.log(`- Retrieved ${devicesJRP.length} devices for Agent ${agentUuidJRP}`);
|
||||
for (const d of devicesJRP) {
|
||||
const isAllowedPrefix = d.ip_address.startsWith('10.0.') ||
|
||||
d.ip_address.startsWith('10.1.') ||
|
||||
d.ip_address.startsWith('10.26.') ||
|
||||
d.ip_address.startsWith('10.43.') ||
|
||||
d.ip_address.startsWith('10.35.') ||
|
||||
d.ip_address.startsWith('10.21.') ||
|
||||
d.ip_address.startsWith('10.7.') ||
|
||||
d.ip_address.startsWith('10.182.') ||
|
||||
d.ip_address.startsWith('10.109.') ||
|
||||
d.ip_address.startsWith('10.181.') ||
|
||||
d.ip_address.startsWith('10.75.') ||
|
||||
d.ip_address.startsWith('10.202.') ||
|
||||
d.ip_address.startsWith('10.93.');
|
||||
const isAllowedMac = ['60:be:b4:1f:05:96'].includes(d.mac_address);
|
||||
|
||||
if (!isAllowedPrefix && !isAllowedMac) {
|
||||
throw new Error(`Non-agent device IP ${d.ip_address} / MAC ${d.mac_address} found in JRP Agent view`);
|
||||
}
|
||||
}
|
||||
console.log('- Verified all returned devices belong to JRP agent\'s subnets or MAC array');
|
||||
|
||||
console.log('\n=== ALL TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
try {
|
||||
runTest();
|
||||
} catch (error) {
|
||||
console.error('\n❌ TEST FAILED:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dbPath = path.join(__dirname, '../netify_data.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log("=== STARTING TDD TEST FOR DYNAMIC AGENTS & USERS ===");
|
||||
|
||||
// 1. Mock agents list returned from API
|
||||
const mockAgents = [
|
||||
{ id: 4897042839, uuid: "2F-TF-1D-GK", label: "2F-TF-1D-GK" },
|
||||
{ id: 4895530456, uuid: "8A-V3-PB-85", label: "8A-V3-PB-85" },
|
||||
{ id: 4894730147, uuid: "F6-2V-DT-8A", label: "F6-2V-DT-8A" },
|
||||
{ id: 4895853843, uuid: "1R-79-J9-YE", label: "1R-79-J9-YE" } // New agent!
|
||||
];
|
||||
|
||||
// 2. Call syncAgentUsers to dynamically seed users
|
||||
const dbModule = require('../database');
|
||||
console.log("- Running syncAgentUsers...");
|
||||
dbModule.syncAgentUsers(mockAgents);
|
||||
|
||||
// 3. Verify users are created
|
||||
const expectedUsernames = [
|
||||
'1r-79-j9-ye',
|
||||
'1R-79-J9-YE',
|
||||
'agent_cpi_balaraja_wan',
|
||||
'2f-tf-1d-gk',
|
||||
'2F-TF-1D-GK',
|
||||
'agent_jrp_cibubur'
|
||||
];
|
||||
|
||||
for (const u of expectedUsernames) {
|
||||
const user = dbModule.getUserByUsername(u);
|
||||
if (!user) {
|
||||
throw new Error(`❌ TEST FAILED: User '${u}' was not created!`);
|
||||
}
|
||||
console.log(`✅ User verified: '${u}' (Role: ${user.role}, Agent UUID: ${user.agent_uuid})`);
|
||||
|
||||
// Verify password matches 123
|
||||
const pwOk = bcrypt.compareSync('123', user.password_hash);
|
||||
if (!pwOk) {
|
||||
throw new Error(`❌ TEST FAILED: Password for user '${u}' is incorrect!`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify data scoping/isolation for 1R-79-J9-YE
|
||||
console.log("- Verifying data scoping/isolation for 1R-79-J9-YE...");
|
||||
const agentUuid = '1R-79-J9-YE';
|
||||
|
||||
const allowedMacs = [
|
||||
'70:85:6c:6d:f7:17', '70:85:6c:81:50:d4', 'a2:cc:8e:7d:39:51',
|
||||
'f2:69:9d:a1:5e:11', '8e:91:0f:6e:24:63'
|
||||
];
|
||||
|
||||
// A. Check flows count
|
||||
const flows = dbModule.getLatestFlows(100, null, agentUuid);
|
||||
console.log(` Scoped flows count: ${flows.length}`);
|
||||
for (const f of flows) {
|
||||
if (!allowedMacs.includes(f.src_mac)) {
|
||||
throw new Error(`❌ TEST FAILED: Flow src_mac '${f.src_mac}' leaked into 1R-79-J9-YE scope!`);
|
||||
}
|
||||
}
|
||||
|
||||
// B. Check devices count
|
||||
const devices = dbModule.getLatestDevices(100, null, agentUuid);
|
||||
console.log(` Scoped devices count: ${devices.length}`);
|
||||
|
||||
// C. Check countries stats
|
||||
const countries = dbModule.getLatestCountries(10, null, agentUuid);
|
||||
console.log(` Scoped countries count: ${countries.length}`);
|
||||
|
||||
console.log("=== ALL DYNAMIC AGENT AND USER TESTS PASSED! ===");
|
||||
process.exit(0);
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* TDD Test Suite: Validasi output file netify_data_export.txt
|
||||
*
|
||||
* Test ini mendefinisikan ATURAN yang harus dipenuhi oleh file output:
|
||||
* 1. File harus ada
|
||||
* 2. File harus tidak kosong
|
||||
* 3. File harus berisi section untuk setiap tabel DB
|
||||
* 4. Setiap section harus memiliki header, jumlah baris, dan data
|
||||
* 5. Data harus berupa JSON valid per baris (JSONL)
|
||||
* 6. File harus berisi metadata (tanggal export, versi, jumlah tabel)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUTPUT_FILE = path.join(__dirname, '../../netify_data_export.txt');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const results = [];
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(' ✅ PASS:', name);
|
||||
passed++;
|
||||
results.push({ name, status: 'PASS' });
|
||||
} catch (e) {
|
||||
console.log(' ❌ FAIL:', name);
|
||||
console.log(' Reason:', e.message);
|
||||
failed++;
|
||||
results.push({ name, status: 'FAIL', reason: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEqual(a, b, message) {
|
||||
if (a !== b) throw new Error(message || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
||||
}
|
||||
|
||||
function assertGreaterThan(a, b, message) {
|
||||
if (a <= b) throw new Error(message || `Expected ${a} > ${b}`);
|
||||
}
|
||||
|
||||
// ─── Required tables that must appear in the export ─────────────────────────
|
||||
const REQUIRED_TABLES = [
|
||||
'app_categories',
|
||||
'bandwidth_apps',
|
||||
'bandwidth_countries',
|
||||
'bandwidth_protocols',
|
||||
'bandwidth_timeline',
|
||||
'bittorrent_hashes',
|
||||
'cities',
|
||||
'continents',
|
||||
'devices',
|
||||
'dhcp_fingerprints',
|
||||
'discovery_os',
|
||||
'dns_queries',
|
||||
'events',
|
||||
'flow_origins',
|
||||
'flow_types',
|
||||
'flows',
|
||||
'http_user_agents',
|
||||
'intel_crypto_mining',
|
||||
'intel_device_discovery',
|
||||
'intel_encryption_audit',
|
||||
'intel_insecure_protocols',
|
||||
'intel_ip_reputation',
|
||||
'intel_server_discovery',
|
||||
'intel_tor_detection',
|
||||
'intel_unencrypted_passwords',
|
||||
'intel_vpn_detection',
|
||||
'interfaces',
|
||||
'ip_versions',
|
||||
'mac_bandwidth',
|
||||
'mdns_hostnames',
|
||||
'netbios_hostnames',
|
||||
'quic_hostnames',
|
||||
'regions',
|
||||
'remote_ips',
|
||||
'sni_hostnames',
|
||||
'ssh_versions',
|
||||
'ssl_server_cn',
|
||||
'threats',
|
||||
'tls_ciphers',
|
||||
'tls_security',
|
||||
'tls_versions',
|
||||
'vlans',
|
||||
];
|
||||
|
||||
// ─── Run Tests ────────────────────────────────────────────────────────────
|
||||
console.log('\n══════════════════════════════════════════════════════════');
|
||||
console.log(' TDD TEST SUITE: netify_data_export.txt Validator');
|
||||
console.log('══════════════════════════════════════════════════════════\n');
|
||||
|
||||
// Test 1: File must exist
|
||||
console.log('[ Group 1: File Existence & Structure ]');
|
||||
test('File netify_data_export.txt harus ada', () => {
|
||||
assert(fs.existsSync(OUTPUT_FILE), `File tidak ditemukan: ${OUTPUT_FILE}`);
|
||||
});
|
||||
|
||||
// Bail early if file doesn't exist
|
||||
if (!fs.existsSync(OUTPUT_FILE)) {
|
||||
console.log('\n ⚠️ File tidak ada, skip remaining tests.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(OUTPUT_FILE, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
test('File tidak boleh kosong (minimal 100 baris)', () => {
|
||||
assertGreaterThan(lines.length, 100, `File hanya ${lines.length} baris`);
|
||||
});
|
||||
|
||||
test('File harus diawali dengan header metadata', () => {
|
||||
assert(content.includes('NETIFY DATA EXPORT'), 'Header NETIFY DATA EXPORT tidak ditemukan');
|
||||
});
|
||||
|
||||
test('File harus berisi tanggal export', () => {
|
||||
assert(content.includes('Export Date:') || content.includes('Generated:'), 'Tanggal export tidak ditemukan');
|
||||
});
|
||||
|
||||
test('File harus berisi total jumlah tabel', () => {
|
||||
assert(content.includes('Total Tables:') || content.includes('Tables:'), 'Info total tabel tidak ditemukan');
|
||||
});
|
||||
|
||||
// Test 2: Section per table
|
||||
console.log('\n[ Group 2: Table Sections ]');
|
||||
REQUIRED_TABLES.forEach(tableName => {
|
||||
test(`Section [${tableName}] harus ada di file`, () => {
|
||||
assert(
|
||||
content.includes(`[TABLE: ${tableName}]`) || content.includes(`TABLE: ${tableName}`),
|
||||
`Section untuk tabel ${tableName} tidak ditemukan`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Test 3: Data format
|
||||
console.log('\n[ Group 3: Data Format & Content ]');
|
||||
|
||||
test('Setiap section tabel harus memiliki info ROW COUNT', () => {
|
||||
assert(content.includes('Row Count:') || content.includes('rows:'), 'Row count info tidak ditemukan');
|
||||
});
|
||||
|
||||
test('Section flows harus berisi data src_ip', () => {
|
||||
const flowSection = content.includes('[TABLE: flows]');
|
||||
if (flowSection) {
|
||||
assert(content.includes('src_ip') || content.includes('"src_ip"'), 'Data src_ip tidak ditemukan di flows');
|
||||
}
|
||||
});
|
||||
|
||||
test('Section bandwidth_apps harus berisi data app_label', () => {
|
||||
assert(content.includes('app_label') || content.includes('"app_label"'), 'app_label tidak ditemukan');
|
||||
});
|
||||
|
||||
test('Section intel_encryption_audit harus berisi risk_level', () => {
|
||||
assert(content.includes('risk_level') || content.includes('"risk_level"'), 'risk_level tidak ditemukan');
|
||||
});
|
||||
|
||||
test('Section devices harus berisi ip_address', () => {
|
||||
assert(content.includes('ip_address') || content.includes('"ip_address"'), 'ip_address tidak ditemukan');
|
||||
});
|
||||
|
||||
// Test 4: Specific data values check
|
||||
console.log('\n[ Group 4: Real Data Validation ]');
|
||||
|
||||
test('File harus mengandung data flows (src_ip format 10.x.x.x atau 192.168.x.x)', () => {
|
||||
const hasRealIP = /10\.\d+\.\d+\.\d+/.test(content) || /192\.168\.\d+\.\d+/.test(content);
|
||||
assert(hasRealIP, 'Tidak ditemukan IP internal (10.x.x.x / 192.168.x.x)');
|
||||
});
|
||||
|
||||
test('File harus mengandung nama aplikasi nyata (YouTube/Facebook/etc)', () => {
|
||||
const hasApp = content.includes('YouTube') || content.includes('Facebook') || content.includes('WhatsApp');
|
||||
assert(hasApp, 'Tidak ada nama aplikasi brand yang dikenal');
|
||||
});
|
||||
|
||||
test('Section intel_unencrypted_passwords harus memiliki data username/severity', () => {
|
||||
assert(content.includes('severity') || content.includes('"severity"'), 'severity tidak ditemukan');
|
||||
});
|
||||
|
||||
test('File mengandung data agent/MAC address', () => {
|
||||
const hasMAC = /[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}/.test(content);
|
||||
assert(hasMAC, 'Tidak ada format MAC address yang valid');
|
||||
});
|
||||
|
||||
test('File mengandung bytes/bandwidth numbers (angka > 1000)', () => {
|
||||
const hasBigNum = /\d{4,}/.test(content);
|
||||
assert(hasBigNum, 'Tidak ada angka bandwidth yang terdeteksi');
|
||||
});
|
||||
|
||||
// Test 5: File size
|
||||
console.log('\n[ Group 5: File Size ]');
|
||||
|
||||
test('File harus cukup besar (minimal 100KB)', () => {
|
||||
const stats = fs.statSync(OUTPUT_FILE);
|
||||
assertGreaterThan(stats.size, 100 * 1024, `File terlalu kecil: ${stats.size} bytes`);
|
||||
});
|
||||
|
||||
// ─── Summary ────────────────────────────────────────────────────────────────
|
||||
console.log('\n══════════════════════════════════════════════════════════');
|
||||
console.log(` RESULTS: ${passed} passed, ${failed} failed`);
|
||||
console.log('══════════════════════════════════════════════════════════\n');
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('❌ TESTS FAILED — implementasi diperlukan\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ ALL TESTS PASSED — file export valid\n');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
const db = require('../database');
|
||||
|
||||
function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR FLOW DESTINATION CORRELATION ===');
|
||||
|
||||
// Fetch all recent flows from getLatestFlows (which automatically calls correlateFlows)
|
||||
const flows = db.getLatestFlows(1000, null, null);
|
||||
if (!Array.isArray(flows)) {
|
||||
throw new Error('Flows should be an array');
|
||||
}
|
||||
console.log(`- Retrieved ${flows.length} flows`);
|
||||
|
||||
let resolvedLocal = 0;
|
||||
let resolvedPublic = 0;
|
||||
|
||||
for (const f of flows) {
|
||||
const dstIp = f.dst_ip;
|
||||
const appLabel = f.app_label;
|
||||
const domain = f.domain;
|
||||
|
||||
if (!dstIp) continue;
|
||||
|
||||
// Check if the destination IP is local Intranet
|
||||
const isIntranet = dstIp.startsWith('10.') || dstIp.startsWith('192.168.');
|
||||
|
||||
if (isIntranet) {
|
||||
// It should be correlated!
|
||||
if (appLabel && appLabel.includes('(') && appLabel.includes(dstIp)) {
|
||||
resolvedLocal++;
|
||||
|
||||
// Assert domain contains port information
|
||||
if (!domain || !domain.startsWith('Port ')) {
|
||||
throw new Error(`Correlated intranet flow has invalid domain sub-label: ${domain}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Public IP check
|
||||
// If it mapped to std port or cached domain
|
||||
if (appLabel && !appLabel.startsWith('Port ') && domain && domain.startsWith('Port ')) {
|
||||
resolvedPublic++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`- Successfully correlated ${resolvedLocal} local/intranet flows`);
|
||||
console.log(`- Successfully correlated ${resolvedPublic} public destination flows`);
|
||||
|
||||
// Verify at least some intranet flows are correlated since JRP and IFG cross-talk or communicate
|
||||
console.log('\nAsserting JRP/IFG intranet destination correlation...');
|
||||
|
||||
// Find a specific flow where dst_ip starts with 10.250.0.
|
||||
const ifgDstFlow = flows.find(f => f.dst_ip && f.dst_ip.startsWith('10.250.0.') && f.app_label.includes('IFG'));
|
||||
if (ifgDstFlow) {
|
||||
console.log(`- Found correlated IFG destination flow:`);
|
||||
console.log(` - Dst IP: ${ifgDstFlow.dst_ip}`);
|
||||
console.log(` - App Label: ${ifgDstFlow.app_label}`);
|
||||
console.log(` - Domain: ${ifgDstFlow.domain}`);
|
||||
} else {
|
||||
console.log('- No IFG destination flows found in this snapshot limit (this is fine if no cross-site traffic occurred in the sample)');
|
||||
}
|
||||
|
||||
// Find a specific JRP destination flow
|
||||
const jrpDstFlow = flows.find(f => f.dst_ip && (f.dst_ip.startsWith('10.1.') || f.dst_ip.startsWith('10.26.')) && f.app_label.includes('JRP'));
|
||||
if (jrpDstFlow) {
|
||||
console.log(`- Found correlated JRP destination flow:`);
|
||||
console.log(` - Dst IP: ${jrpDstFlow.dst_ip}`);
|
||||
console.log(` - App Label: ${jrpDstFlow.app_label}`);
|
||||
console.log(` - Domain: ${jrpDstFlow.domain}`);
|
||||
} else {
|
||||
console.log('- No JRP destination flows found in this snapshot limit');
|
||||
}
|
||||
|
||||
// 3. Test getLatestBandwidthApps correlation
|
||||
console.log('\nAsserting Top Apps correlation...');
|
||||
const jrpAgentUuid = '2F-TF-1D-GK';
|
||||
const apps = db.getLatestBandwidthApps(20, null, jrpAgentUuid);
|
||||
|
||||
let rawPortsFound = 0;
|
||||
let correlatedPortsFound = 0;
|
||||
for (const app of apps) {
|
||||
if (app.app_label.startsWith('Port ')) {
|
||||
rawPortsFound++;
|
||||
} else if (app.app_label.includes('Port') && app.app_label.includes('(')) {
|
||||
correlatedPortsFound++;
|
||||
}
|
||||
}
|
||||
console.log(`- Retrieved ${apps.length} top apps`);
|
||||
console.log(`- Raw Port labels remaining: ${rawPortsFound}`);
|
||||
console.log(`- Correlated Port labels: ${correlatedPortsFound}`);
|
||||
|
||||
if (rawPortsFound > 0) {
|
||||
throw new Error(`Found ${rawPortsFound} raw port labels that should have been correlated!`);
|
||||
}
|
||||
|
||||
console.log('\n=== ALL FLOW CORRELATION TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
try {
|
||||
runTest();
|
||||
} catch (err) {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const dbModule = require('../database');
|
||||
|
||||
console.log("=== STARTING TDD TEST FOR NOTIFICATIONS & INTERVALS ===");
|
||||
|
||||
// 1. Test Data Interval Function
|
||||
try {
|
||||
const interval = dbModule.getDataInterval();
|
||||
console.log("Data Interval retrieved:", interval);
|
||||
assert.ok(interval.start, "Start timestamp must exist");
|
||||
assert.ok(interval.end, "End timestamp must exist");
|
||||
assert.ok(new Date(interval.start).getTime() <= new Date(interval.end).getTime(), "Start timestamp must be <= End timestamp");
|
||||
console.log("✓ Test 1 Passed: getDataInterval returns correct timestamp range");
|
||||
} catch (e) {
|
||||
console.error("Test 1 Failed:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Test Events & Threats Availability
|
||||
try {
|
||||
const events = dbModule.getLatestEvents(10);
|
||||
const threats = dbModule.getLatestThreats(10);
|
||||
console.log(`Latest events count: ${events.length}, latest threats count: ${threats.length}`);
|
||||
assert.ok(Array.isArray(events), "Events must be an array");
|
||||
assert.ok(Array.isArray(threats), "Threats must be an array");
|
||||
console.log("✓ Test 2 Passed: getLatestEvents and getLatestThreats return arrays");
|
||||
} catch (e) {
|
||||
console.error("Test 2 Failed:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("=== ALL TESTS PASSED SUCCESSFULLY! ===");
|
||||
@@ -1,63 +0,0 @@
|
||||
const { fetchSecurityDevices } = require('../netify');
|
||||
|
||||
async function runTest() {
|
||||
console.log('=== STARTING TDD TEST FOR SECURITY DEVICES TENANT ISOLATION ===\n');
|
||||
|
||||
// Test Case 1: JRP Cibubur Scope ('2F-TF-1D-GK')
|
||||
console.log('Running Test 1: Scoping JRP Cibubur (2F-TF-1D-GK)...');
|
||||
const jrpDevices = await fetchSecurityDevices(null, '2F-TF-1D-GK');
|
||||
console.log(`- Retrieved ${jrpDevices.length} security devices.`);
|
||||
|
||||
for (const dev of jrpDevices) {
|
||||
const ip = dev.ip_address;
|
||||
|
||||
// Assert that no IFG IP address (starts with 10.6.x.x) is leaked
|
||||
if (ip && ip.startsWith('10.6.')) {
|
||||
throw new Error(`DATA LEAK DETECTED: IFG device ${ip} leaked into JRP scope!`);
|
||||
}
|
||||
|
||||
// Assert that the IP belongs to one of JRP subnets or is an authorized loopback/link-local
|
||||
const isJrpIp = ip.startsWith('10.1.') || ip.startsWith('10.0.') || ip.startsWith('10.26.') ||
|
||||
ip.startsWith('10.43.') || ip.startsWith('10.35.') || ip.startsWith('10.21.') ||
|
||||
ip.startsWith('10.7.') || ip.startsWith('10.182.') || ip.startsWith('10.109.') ||
|
||||
ip.startsWith('10.181.') || ip.startsWith('10.75.') || ip.startsWith('10.202.') ||
|
||||
ip.startsWith('10.93.') || ip.startsWith('fe80:') || ip.startsWith('10.102.');
|
||||
if (!isJrpIp) {
|
||||
throw new Error(`IP ${ip} does not match any JRP subnet range!`);
|
||||
}
|
||||
}
|
||||
console.log('✓ Test 1 Passed! No cross-tenant leakages for JRP.');
|
||||
|
||||
// Test Case 2: IFG Scope ('8A-V3-PB-85')
|
||||
console.log('\nRunning Test 2: Scoping IFG (8A-V3-PB-85)...');
|
||||
const ifgDevices = await fetchSecurityDevices(null, '8A-V3-PB-85');
|
||||
console.log(`- Retrieved ${ifgDevices.length} security devices.`);
|
||||
|
||||
for (const dev of ifgDevices) {
|
||||
const ip = dev.ip_address;
|
||||
|
||||
// Assert that no JRP IP address is leaked
|
||||
const isJrpIp = ip.startsWith('10.1.') || ip.startsWith('10.0.') || ip.startsWith('10.26.') ||
|
||||
ip.startsWith('10.43.') || ip.startsWith('10.35.') || ip.startsWith('10.21.') ||
|
||||
ip.startsWith('10.7.') || ip.startsWith('10.182.') || ip.startsWith('10.109.') ||
|
||||
ip.startsWith('10.181.') || ip.startsWith('10.75.') || ip.startsWith('10.202.') ||
|
||||
ip.startsWith('10.93.') || ip.startsWith('10.102.');
|
||||
if (isJrpIp) {
|
||||
throw new Error(`DATA LEAK DETECTED: JRP device ${ip} leaked into IFG scope!`);
|
||||
}
|
||||
|
||||
// Assert that the IP belongs to IFG subnets (10.6.x.x) or link-local
|
||||
const isIfgIp = ip.startsWith('10.6.') || ip.startsWith('fe80:');
|
||||
if (!isIfgIp) {
|
||||
throw new Error(`IP ${ip} does not match IFG subnet range!`);
|
||||
}
|
||||
}
|
||||
console.log('✓ Test 2 Passed! No cross-tenant leakages for IFG.');
|
||||
|
||||
console.log('\n=== ALL SECURITY TENANT ISOLATION TESTS PASSED SUCCESSFULLY! ===');
|
||||
}
|
||||
|
||||
runTest().catch(err => {
|
||||
console.error('\n❌ TEST FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// Test: cari field name yang valid untuk SNI hostname di API
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env.local') });
|
||||
|
||||
const API_KEY = process.env.NETIFY_API_KEY;
|
||||
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||
|
||||
const headers = {
|
||||
'x-api-key': API_KEY,
|
||||
'x-net-site': SITE_UUID,
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
async function tryField(field) {
|
||||
try {
|
||||
const url = `${BASE}/data/stats/top/${field}/download`;
|
||||
const res = await axios.get(url, { headers, params: { filter_interval: 1440, settings_limit: 5 }, timeout: 10000 });
|
||||
const data = res.data?.data;
|
||||
if (data && Array.isArray(data) && data.length > 0) {
|
||||
console.log(`✅ ${field}: OK, count=${data.length}, keys=${Object.keys(data[0])}`);
|
||||
console.log(` First: ${JSON.stringify(data[0]).slice(0, 200)}`);
|
||||
} else {
|
||||
console.log(`⚠️ ${field}: OK but empty`);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const msg = err.response?.data?.status_message || err.message;
|
||||
console.log(`❌ ${field}: ${status} - ${msg?.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
// Try all possible SNI-related field names
|
||||
const candidates = [
|
||||
'https_sni_hostname',
|
||||
'sni_hostname',
|
||||
'ssl_sni',
|
||||
'tls_sni',
|
||||
'ssl_server_cn',
|
||||
'quic_hostname',
|
||||
'hostname'
|
||||
];
|
||||
|
||||
console.log('=== Testing SNI/Hostname field names ===\n');
|
||||
for (const f of candidates) {
|
||||
await tryField(f);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
Reference in new issue
Block a user