chore: deep cleanup of test scripts and implement glassmorphism UI enhancements

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-07 00:35:54 +07:00
1 parent 7777b62305
commit 58040d6e6c
81 files changed
+2247 -6734

No files matched your search

+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+31 -2
View File
@@ -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 });
});
+26
View File
@@ -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);
});
-21
View File
@@ -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);
}
});
-125
View File
@@ -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);
});
-55
View File
@@ -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);
});
-45
View File
@@ -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);
});
-99
View File
@@ -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);
}
-199
View File
@@ -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);
});
-66
View File
@@ -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);
}
-118
View File
@@ -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);
-214
View File
@@ -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);
}
-103
View File
@@ -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);
});
-52
View File
@@ -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