'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const net = require('node:net'); const http = require('node:http'); const { spawn } = require('node:child_process'); const { trafficResetPeriodKey, armTrafficReset, applyMonthlyTrafficResets, parseResetDay } = require('../lib/reset'); const { parseLinkList, validateLinkUri, fetchExternalSource } = require('../lib/extsub'); const { aggregateEntries } = require('../lib/subscriptions'); const { customerOnline, customerSpeedBps, nodeOnlineCustomerIds } = require('../lib/presence'); // ---------- monthly reset (Node port of panel-go/reset.go) ---------- test('reset period key clamps to month end and flips at the reset instant', () => { // Day 31 in February fires on Feb 28. assert.equal(trafficResetPeriodKey(new Date(2026, 1, 27, 23, 59), 31), '2026-01'); assert.equal(trafficResetPeriodKey(new Date(2026, 1, 28, 0, 0), 31), '2026-02'); assert.equal(trafficResetPeriodKey(new Date(2026, 0, 31, 0, 0), 31), '2026-01'); assert.equal(trafficResetPeriodKey(new Date(2026, 0, 30, 23, 59), 31), '2025-12'); assert.equal(trafficResetPeriodKey(new Date(2026, 4, 15, 0, 0), 15), '2026-05'); assert.equal(trafficResetPeriodKey(new Date(2026, 4, 14, 23, 59), 15), '2026-04'); }); test('monthly reset arms on first sight, fires once per period, catches up after downtime', () => { const data = { customers: [{ id: 'c1', trafficResetDay: 15, usedBytes: 500, usedUplinkBytes: 200, usedDownlinkBytes: 300 }] }; const customer = data.customers[0]; // First sight only arms. let done = applyMonthlyTrafficResets(data, new Date(2026, 4, 20)); assert.equal(done.length, 0); assert.equal(customer.usedBytes, 500); assert.equal(customer.trafficResetPeriod, '2026-05'); // Same period: nothing. done = applyMonthlyTrafficResets(data, new Date(2026, 5, 14)); assert.equal(done.length, 0); // Crossing the instant fires exactly once (even when the panel was down // across two boundaries, only the current period applies). done = applyMonthlyTrafficResets(data, new Date(2026, 6, 20)); assert.equal(done.length, 1); assert.equal(customer.usedBytes, 0); assert.equal(customer.trafficResetPeriod, '2026-07'); done = applyMonthlyTrafficResets(data, new Date(2026, 6, 21)); assert.equal(done.length, 0); }); test('parseResetDay validates like the Go implementation', () => { assert.equal(parseResetDay(undefined), 0); assert.equal(parseResetDay(null), 0); assert.equal(parseResetDay(0), 0); assert.equal(parseResetDay(31), 31); assert.throws(() => parseResetDay(32), /0–31/); assert.throws(() => parseResetDay(-1), /非负整数/); assert.throws(() => parseResetDay(1.5), /非负整数/); const customer = { trafficResetDay: 10 }; armTrafficReset(customer, new Date(2026, 4, 20)); assert.equal(customer.trafficResetPeriod, '2026-05'); }); // ---------- external subscription parsing ---------- test('parseLinkList accepts base64 and plaintext, drops unsupported schemes', () => { const plain = [ 'vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#Node%20A', 'ss://YWVzLTEyOC1nY206cGFzcw@b.example.com:8388#NodeB', 'vmess://eyJ2IjoiMiJ9', 'trojan://pw@c.example.com:443#T', 'not a link', 'hysteria2://pw@d.example.com:443?sni=d.example.com#NodeD', ].join('\n'); const fromPlain = parseLinkList(plain); assert.equal(fromPlain.items.length, 3); assert.equal(fromPlain.dropped, 3); assert.equal(fromPlain.items[0].name, 'Node A'); const fromB64 = parseLinkList(Buffer.from(plain, 'utf8').toString('base64')); assert.deepEqual(fromB64, fromPlain); assert.equal(validateLinkUri('socks5://u:p@h:1080'), true); assert.equal(validateLinkUri('http://example.com/sub'), false); }); test('fetchExternalSource follows the SSRF guard and parses a local fixture when allowed', async (t) => { const body = Buffer.from('vless://11111111-1111-1111-1111-111111111111@a.example.com:443#A\n', 'utf8').toString('base64'); const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(body); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); t.after(() => server.close()); const url = `http://127.0.0.1:${server.address().port}/sub`; const blocked = await fetchExternalSource({ url }, {}); assert.equal(blocked.ok, false); assert.match(blocked.error, /非公网/); const allowed = await fetchExternalSource({ url }, { allowPrivateSubSources: true }); assert.equal(allowed.ok, true); assert.equal(allowed.items.length, 1); assert.equal(allowed.items[0].name, 'A'); }); // ---------- subscription aggregation merge ---------- function aggData() { return { servers: [{ id: 's1', publicAddress: 'a.example.com', tlsDomain: '' }], nodes: [ { id: 'n2', name: '第二', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1080, sortIndex: 1, networkMode: 'ipv4' }, { id: 'n1', name: '第一', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1081, sortIndex: 0, networkMode: 'ipv4' }, ], nodeClients: [ { id: 'nc2', nodeId: 'n2', customerId: 'c1', status: 'active', credentials: { clientId: 'u2', relayPassword: 'p2', shortId: 'x' } }, { id: 'nc1', nodeId: 'n1', customerId: 'c1', status: 'active', credentials: { clientId: 'u1', relayPassword: 'p1', shortId: 'x' } }, ], subSourceCache: { src1: { fetchedAt: 'x', okAt: 'x', failCount: 0, lastError: null, droppedCount: 0, items: [ { uri: 'vless://11111111-1111-1111-1111-111111111111@ext.example.com:443#ExtNode', name: 'ExtNode' }, { uri: 'socks5://ng%3Ac1:p1@a.example.com:1081#dup', name: 'dup' }, ] }, }, }; } test('aggregateEntries orders own by sortIndex, then manual, then external, with dedup', () => { const data = aggData(); const customer = { id: 'c1', manualLinks: [{ id: 'lnk1', uri: 'hysteria2://pw@m.example.com:443?sni=m.example.com#Manual', remark: '', enabled: true }], externalSources: [{ id: 'src1', url: 'https://src.example.com/sub', remark: '机场', enabled: true }], }; const items = aggregateEntries(data, customer); const names = items.map((item) => decodeURIComponent(item.clientUri.slice(item.clientUri.lastIndexOf('#') + 1))); // The external socks5 entry duplicates own node n1 (same host/port/credentials) and is dropped. assert.deepEqual(names, ['第一', '第二', 'Manual', '[机场] ExtNode']); }); test('aggregateEntries with no aggregation fields matches own entries only', () => { const data = aggData(); const items = aggregateEntries(data, { id: 'c1' }); assert.equal(items.length, 2); }); // ---------- presence ---------- test('presence window and speed freshness', () => { const now = Date.parse('2026-10-21T00:00:00.000Z'); const recent = new Date(now - 60000).toISOString(); const stale = new Date(now - 600000).toISOString(); assert.equal(customerOnline({ lastOnlineAt: recent }, now), true); assert.equal(customerOnline({ lastOnlineAt: stale }, now), false); assert.equal(customerOnline({}, now), false); const customer = { serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: recent, lastActiveAt: recent } } }; assert.deepEqual(customerSpeedBps(customer, now), { up: 100, down: 200 }); assert.deepEqual(customerSpeedBps({ serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: stale } } }, now), { up: 0, down: 0 }); const data = { customers: [{ id: 'c1', lastOnlineAt: stale, serverUsage: { 'srv:s1': { lastActiveAt: recent } } }], nodeClients: [{ nodeId: 'n1', customerId: 'c1', status: 'active' }], observations: [], }; assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's1' }, now), ['c1']); assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's2' }, now), []); }); // ---------- API integration: sort / reorder / credentials / alignment ---------- async function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } async function waitFor(url) { for (let i = 0; i < 60; i += 1) { try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('test server did not start'); } test('api: node sortIndex, reorder, credentials, customer node alignment, presence', async (t) => { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-ui-')); const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' }, stdio: ['ignore', 'pipe', 'pipe'], }); t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); }); const base = `http://127.0.0.1:${port}`; await waitFor(`${base}/healthz`); const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) }); const session = await login.json(); const cookie = login.headers.get('set-cookie').split(';')[0]; const request = async (pathname, method = 'GET', body) => { const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); const json = await response.json(); assert.ok(response.ok, `${method} ${pathname}: ${JSON.stringify(json)}`); return json; }; const server = (await request('/api/servers', 'POST', { name: 'S1', role: 'hybrid', publicAddress: 's1.example.com' })).server; assert.equal(server.enabled, true); const n1 = (await request('/api/nodes', 'POST', { name: 'N1', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node; const n2 = (await request('/api/nodes', 'POST', { name: 'N2', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node; assert.equal(n1.sortIndex, 0); assert.equal(n2.sortIndex, 1); // Reorder flips list order. await request('/api/nodes/reorder', 'POST', { ids: [n2.id, n1.id] }); let listed = (await request('/api/nodes')).nodes; assert.deepEqual(listed.map((node) => node.name), ['N2', 'N1']); assert.deepEqual(listed.map((node) => node.sortIndex), [0, 1]); // Create with nodeIds alignment + monthly reset + aggregation fields. const customer = (await request('/api/customers', 'POST', { name: '对齐客户', trafficResetDay: 15, nodeIds: [n1.id], manualLinks: [{ uri: 'vless://11111111-1111-1111-1111-111111111111@x.example.com:443#M', remark: '手动一' }], })).customer; assert.equal(customer.trafficResetDay, 15); assert.equal(customer.trafficResetPeriod, trafficResetPeriodKey(new Date(), 15)); assert.equal(customer.manualLinks.length, 1); let nodesView = await request('/api/nodes'); assert.equal(nodesView.nodeClients.filter((nc) => nc.customerId === customer.id).length, 1); // PATCH alignment swaps the binding set. await request(`/api/customers/${customer.id}`, 'PATCH', { nodeIds: [n2.id] }); nodesView = await request('/api/nodes'); const bindings = nodesView.nodeClients.filter((nc) => nc.customerId === customer.id); assert.equal(bindings.length, 1); assert.equal(bindings[0].nodeId, n2.id); // Credentials view + regeneration rotates the binding credential. const before = await request(`/api/customers/${customer.id}/credentials`); assert.equal(before.items.length, 1); assert.ok(before.items[0].clientId); assert.equal(before.subscriptionToken, customer.subscriptionToken); const regenerated = (await request(`/api/customers/${customer.id}/credentials/${before.items[0].nodeClientId}/regenerate`, 'POST', {})).item; assert.notEqual(regenerated.clientId, before.items[0].clientId); assert.notEqual(regenerated.password, before.items[0].password); // Presence: enroll an agent and report usage twice; the customer turns // online after the first positive delta. const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {}); const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'h' }) }); const agentKey = (await enrolled.json()).agentKey; const usage = (samples) => fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ samples, epoch: 'e1' }) }); await usage([{ kind: 'user', id: customer.id, uplink: 1000, downlink: 2000 }]); const customers = (await request('/api/customers')).customers; const view = customers.find((item) => item.id === customer.id); assert.equal(view.online, true); assert.ok(view.lastActiveAt); // System status endpoint shape. const system = await request('/api/system'); assert.ok('host' in system && 'network' in system && 'history' in system && 'ips' in system); assert.equal(system.network.customersTotal, 1); });