Files
NexusGate/test/uireskin.test.js

257 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});