Files
NexusGate/test/api.test.js
T
Muse 084afeb8f4 3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
2026-10-19 22:23:40 +08:00

389 lines
30 KiB
JavaScript

'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 { spawn } = require('node:child_process');
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('admin can create nodes and manage shared-client lifecycle', async (t) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-api-'));
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 frontend = await fetch(`${base}/app.js`);
assert.equal(frontend.headers.get('cache-control'), 'no-store');
const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) });
assert.equal(login.status, 200);
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, JSON.stringify(json));
return json;
};
const relay = (await request('/api/servers', 'POST', { name: '中转 01', role: 'relay', region: 'SG', publicAddress: 'relay.example.com' })).server;
const exit = (await request('/api/servers', 'POST', { name: '落地 01', role: 'exit', region: 'JP', publicAddress: 'exit.example.com' })).server;
const customer = (await request('/api/customers', 'POST', { name: '客户 A', ipLimit: 2, trafficLimitBytes: 1073741824 })).customer;
const editedRelay = (await request(`/api/servers/${relay.id}`, 'PATCH', { name: '新加坡入口 01', publicAddressV6: '2001:db8::10' })).server;
assert.equal(editedRelay.name, '新加坡入口 01');
const editedCustomer = (await request(`/api/customers/${customer.id}`, 'PATCH', { expiresAt: '2030-12-31T23:59:00+08:00', tags: ['VIP'] })).customer;
assert.equal(editedCustomer.expiresAt, '2030-12-31T15:59:00.000Z');
// Shared node: one entry inbound serves many customers; deploy needs a client first.
const node = (await request('/api/nodes', 'POST', { name: 'SG → JP', serverId: relay.id, protocol: 'vless-reality-vision',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node;
const prematureDeploy = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(prematureDeploy.status, 400);
assert.match((await prematureDeploy.json()).message, /至少一个客户/);
await request(`/api/nodes/${node.id}/assign`, 'POST', { customerId: customer.id });
const premature = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(premature.status, 409);
const agentKeys = {};
for (const server of [relay, exit]) {
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
const response = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'test-agent' }) });
assert.equal(response.status, 201);
agentKeys[server.id] = (await response.json()).agentKey;
}
const certSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ domain: 'entry.example.com' }) });
assert.equal(certSync.status, 200);
assert.equal((await request('/api/servers')).servers.find((item) => item.id === relay.id).tlsDomain, 'entry.example.com');
const idleDatabase = await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8');
for (let count = 0; count < 10; count += 1) {
for (const server of [relay, exit]) {
const headers = { authorization: `Bearer ${agentKeys[server.id]}`, 'content-type': 'application/json' };
const poll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers, body: '{}' });
assert.deepEqual(await poll.json(), { job: null });
const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers,
body: JSON.stringify({ version: '0.6.8', system: { memoryAvailable: 512 * 1024 * 1024 } }) });
assert.equal(heartbeat.status, 200);
}
}
assert.equal(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'), idleDatabase);
const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ domain: 'invalid/domain' }) });
assert.equal(badCertSync.status, 400);
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.4.0', engine: { status: 'error', detail: 'Xray config validation failed' } }) });
// A broken core can be repaired by applying a new resource; keep the Agent job queue available.
const engineBlocked = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(engineBlocked.status, 202);
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.4.0', engine: { status: 'ready', detail: 'active' } }) });
const jobs = await request('/api/jobs');
assert.equal(jobs.jobs.length, 2);
assert.ok(jobs.jobs.every((job) => job.status === 'queued'));
const exitPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}` } });
const exitJob = (await exitPoll.json()).job;
const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: false, error: 'PrivateKey: example_secret Password (PublicKey): example_public' }) });
assert.equal(failed.status, 200);
assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/);
assert.doesNotMatch(JSON.stringify(await request('/api/nodes')), /example_secret/);
// Editing a live node rebuilds immediately (3x-ui style); protocol changes reissue client credentials.
const changed = await request(`/api/nodes/${node.id}`, 'PATCH', { realityServerName: 'www.tesla.com' });
assert.equal(changed.node.status, 'deploying');
const customerB = (await request('/api/customers', 'POST', { name: '客户 B', ipLimit: 1, deviceLimit: 1 })).customer;
const direct = (await request('/api/nodes', 'POST', { name: 'IPv6 单机节点', serverId: relay.id, protocol: 'shadowsocks-2022-aes256',
networkMode: 'ipv6', topology: 'direct' })).node;
await request(`/api/nodes/${direct.id}/assign`, 'POST', { customerId: customerB.id });
await request(`/api/nodes/${direct.id}/deploy`, 'POST', {});
const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`);
assert.equal(noActive.status, 503);
const enrollment = await request(`/api/servers/${relay.id}/enrollment-token`, 'POST', {});
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: enrollment.token, hostname: 'test-agent' }) });
const { agentKey } = await enrolled.json();
assert.equal(enrolled.status, 201);
agentKeys[relay.id] = agentKey; // re-enrollment revokes the previous agent key
const completeNext = async (key) => {
const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } });
const { job } = await polled.json();
assert.ok(job);
const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) });
assert.equal(completed.status, 200);
return job;
};
// Drain every queued job on both agents (entry + exit + rebuild from the PATCH above).
for (let i = 0; i < 8; i += 1) {
let drained = true;
for (const key of Object.values(agentKeys)) {
const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } });
const { job } = await polled.json();
if (job) {
drained = false;
await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) });
}
}
if (drained) break;
}
const activeNodes = (await request('/api/nodes')).nodes;
assert.equal(activeNodes.find((item) => item.id === node.id).status, 'active');
assert.equal(activeNodes.find((item) => item.id === direct.id).status, 'active');
const usageReport = async (uplink, downlink, epoch = 'sing-box-start-1') => {
const response = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch, samples: [{ kind: 'user', id: customerB.id, uplink, downlink }] }) });
assert.equal(response.status, 200);
};
await usageReport(150, 350);
await usageReport(150, 350); // Retry after a lost HTTP response must not double count.
await usageReport(250, 900);
await usageReport(150, 350); // A late report from the same process must not count a false reset.
await usageReport(10, 20, 'sing-box-start-2'); // sing-box restarted, and its counters began from zero.
await usageReport(250, 900, 'sing-box-start-1'); // The old request can arrive after the new process reported.
const metered = (await request('/api/customers')).customers.find((item) => item.id === customerB.id);
assert.equal(metered.usedBytes, 1180);
assert.equal(metered.usedUplinkBytes, 260);
assert.equal(metered.usedDownlinkBytes, 920);
// Inbound-level traffic lands on the node, not the customer.
await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'sing-box-start-2', samples: [{ kind: 'inbound', id: direct.id, uplink: 260, downlink: 920 }] }) });
const meteredNode = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id);
assert.deepEqual(meteredNode.meteredTraffic, { uplink: 260, downlink: 920 });
const raw = await fetch(`${base}/s/${customerB.subscriptionToken}/raw`);
assert.equal(raw.status, 200);
assert.match(raw.headers.get('subscription-userinfo'), /upload=260; download=920;/);
await usageReport(1000, 2000, 'sing-box-start-3'); // A fast restart can exceed previous counters before polling.
await usageReport(1000, 2000, 'sing-box-start-3');
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180);
assert.match(await raw.text(), /^ss:\/\//);
assert.equal(raw.headers.get('cache-control'), 'no-store, private');
const encoded = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`);
assert.match(Buffer.from(await encoded.text(), 'base64').toString('utf8'), /^ss:\/\//);
const clash = await fetch(`${base}/s/${customerB.subscriptionToken}/clash`);
const clashText = await clash.text();
assert.match(clashText, /^ - name: /m);
assert.match(clashText, /^ type: "ss"$/m);
const smart = await fetch(`${base}/s/${customerB.subscriptionToken}/clash-smart`);
const smartText = await smart.text();
assert.match(smartText, /GEOSITE,category-ads-all,REJECT/);
assert.match(smartText, /GEOSITE,facebook,Meta 服务/);
const surge = await fetch(`${base}/s/${customerB.subscriptionToken}/surge`);
assert.match(await surge.text(), /\[Proxy Group\]/);
const singbox = await fetch(`${base}/s/${customerB.subscriptionToken}/singbox`);
assert.equal((await singbox.json()).outbounds[1].type, 'shadowsocks');
const rotated = (await request(`/api/customers/${customerB.id}/rotate-subscription`, 'POST', {})).customer;
assert.notEqual(rotated.subscriptionToken, customerB.subscriptionToken);
assert.equal((await fetch(`${base}/s/${customerB.subscriptionToken}/raw`)).status, 404);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 200);
const extraClient = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client', 'user-agent': 'Other Client/1.0' } });
assert.equal(extraClient.status, 429);
const access = await request(`/api/customers/${customerB.id}/access`);
assert.equal(access.subscriptionClients, 1);
assert.ok(access.events.some((item) => item.status === 429 && item.reason.includes('超限')));
assert.ok(access.events.some((item) => item.status === 200 && item.bytes > 0));
assert.ok(access.events.every((item) => !('clientKey' in item)));
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).subscriptionClientCount, 1);
await request(`/api/customers/${customerB.id}/access`, 'DELETE');
assert.equal((await request(`/api/customers/${customerB.id}/access`)).events.length, 0);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200);
const nodeClients = (await request('/api/nodes')).nodeClients;
const originalUri = nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 2000 });
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 403);
const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method: 'PATCH',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'active' }) });
assert.equal(prematureResume.status, 409);
const deletePoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } });
const deleteJob = (await deletePoll.json()).job;
assert.equal(deleteJob.action, 'delete_resource');
await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).status, 'suspended');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
const restoringSubscription = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`);
assert.equal(restoringSubscription.status, 503);
assert.equal(restoringSubscription.headers.get('retry-after'), '15');
assert.match((await restoringSubscription.json()).message, /正在恢复/);
const applyPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } });
const applyJob = (await applyPoll.json()).job;
assert.equal(applyJob.action, 'apply_resource');
await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const revivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
assert.equal(revivedUri, originalUri);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200);
// Raising the limit while a delete is already running still applies only after cleanup.
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(inFlight.action, 'delete_resource');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const afterDelete = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id);
assert.equal(afterDelete.status, 'deploying'); // resume queued the apply behind the running delete
const reapply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(reapply.action, 'apply_resource');
await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
// A queued deletion can be cancelled before it touches the existing node.
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'active');
assert.equal((await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job, null);
// A removed node keeps its client assignments; redeploying restores the same URI.
await request(`/api/nodes/${direct.id}/remove`, 'POST', {});
const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/nodes/${direct.id}/deploy`, 'POST', {});
const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const redeployedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
assert.equal(redeployedUri, originalUri);
// A forward node suspends both exit and entry, and restores them with the same URI.
const customerC = (await request('/api/customers', 'POST', { name: '配额恢复测试', trafficLimitBytes: 100 })).customer;
const forward = (await request('/api/nodes', 'POST', { name: '保留节点', serverId: relay.id, protocol: 'vless-reality-vision',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node;
await request(`/api/nodes/${forward.id}/assign`, 'POST', { customerId: customerC.id });
await request(`/api/nodes/${forward.id}/deploy`, 'POST', {});
await completeNext(agentKeys[exit.id]);
await completeNext(agentKey);
const forwardUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri;
const forwardUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'fwd-1', samples: [{ kind: 'user', id: customerC.id, uplink: 2, downlink: 5 }] }) });
assert.equal(forwardUsage.status, 200);
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 1 });
assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).action, 'delete_resource');
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === forward.id).status, 'draft');
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 100 });
assert.equal((await completeNext(agentKey)).action, 'apply_resource');
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503);
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource');
const forwardRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri;
assert.equal(forwardRevivedUri, forwardUri);
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 200);
// AnyTLS is a separate entry engine, but follows the same quota lifecycle.
await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain: 'entry.example.com' });
const anyCustomer = (await request('/api/customers', 'POST', { name: 'AnyTLS 客户', trafficLimitBytes: 500 })).customer;
const anyNode = (await request('/api/nodes', 'POST', { name: 'AnyTLS → VLESS', serverId: relay.id, protocol: 'anytls',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'vless-tcp' })).node;
await request(`/api/nodes/${anyNode.id}/assign`, 'POST', { customerId: anyCustomer.id });
const oldAgent = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(oldAgent.status, 409);
assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/);
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: false, certificates: ['entry.example.com'] } }) });
const missingEngine = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(missingEngine.status, 409);
assert.match((await missingEngine.json()).message, /ng-agent engine install/);
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: true, certificates: ['entry.example.com'] } }) });
await request(`/api/nodes/${anyNode.id}/deploy`, 'POST', {});
const anyExitJob = await completeNext(agentKeys[exit.id]);
assert.equal(anyExitJob.payload.resource.inbounds[0].type, 'vless');
const anyEntryJob = await completeNext(agentKey);
assert.equal(anyEntryJob.payload.resource.engine, 'sing-box');
assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless');
const anyUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri;
assert.match(anyUri, /^anytls:\/\//);
assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS');
assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/);
const anyUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST',
headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ samples: [{ kind: 'inbound', id: anyNode.id, uplink: 120, downlink: 400, epoch: 'sing-box:123:456' }] }) });
assert.equal(anyUsage.status, 200);
assert.deepEqual((await request('/api/nodes')).nodes.find((item) => item.id === anyNode.id).meteredTraffic,
{ uplink: 120, downlink: 400 });
// Customer-level usage for the quota test below.
await fetch(`${base}/api/agent/usage`, { method: 'POST',
headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'sb-user-1', samples: [{ kind: 'user', id: anyCustomer.id, uplink: 300, downlink: 300 }] }) });
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 500 });
assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).action, 'delete_resource');
assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403);
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 1000 });
const anyReapply = await completeNext(agentKey);
assert.equal(anyReapply.payload.resource.engine, 'sing-box');
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource');
const anyRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri;
assert.equal(anyRevivedUri, anyUri);
// Exit-server source addresses belong to relay machines, never to client IP quota.
await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '198.51.100.1' }] }) });
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 0);
const observed = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.1' }] }) });
assert.equal(observed.status, 200);
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 1);
const excessIp = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.2' }] }) });
assert.equal(excessIp.status, 200);
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).suspendReason, 'ip_limit');
// Deleting a node removes it from the UI at once and hard-deletes after agents confirm.
await request(`/api/nodes/${node.id}/remove`, 'POST', {});
const deleted = await request(`/api/nodes/${node.id}`, 'DELETE');
assert.equal(deleted.ok, true);
const afterNodeDelete = await request('/api/nodes');
assert.equal(afterNodeDelete.nodes.some((item) => item.id === node.id), false);
assert.equal(afterNodeDelete.nodeClients.some((item) => item.nodeId === node.id), false);
const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'));
assert.equal(source.nodes.some((item) => item.id === node.id), true);
const removedCustomer = await request(`/api/customers/${customer.id}`, 'DELETE');
assert.equal(removedCustomer.ok, true);
assert.equal((await fetch(`${base}/s/${customer.subscriptionToken}/raw`)).status, 404);
const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method: 'DELETE', headers: { cookie, 'x-csrf-token': session.csrf } });
assert.equal(blocked.status, 409);
const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {});
assert.equal(retry.queued, 0);
const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'FORGET', name: '落地 01' }) });
assert.equal(unsafeForget.status, 409);
const renamed = await request('/api/account', 'PATCH', { username: 'owner_2', currentPassword: 'test-password', newPassword: 'new-password-456' });
assert.equal(renamed.reauthenticate, true);
const relogin = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'owner_2', password: 'new-password-456' }) });
assert.equal(relogin.status, 200);
});