Files
NexusGate/test/api.test.js
T

351 lines
28 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 resources and queue a mixed-protocol chain', 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');
const chain = (await request('/api/chains', 'POST', { name: 'SG → JP', relayServerIds: [relay.id], exitServerId: exit.id, customerIds: [customer.id], relayProtocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128' })).chain;
const premature = await fetch(`${base}/api/chains/${chain.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/chains/${chain.id}/deploy`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' });
assert.equal(engineBlocked.status, 202);
const deployed = await engineBlocked.json();
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' } }) });
assert.equal(deployed.deployments.length, 2);
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/chains')), /example_secret/);
const changed = await request(`/api/chains/${chain.id}`, 'PATCH', { realityServerName: 'www.tesla.com' });
assert.equal(changed.requiresRedeploy, true);
assert.equal(changed.chain.status, 'changes_pending');
const customerB = (await request('/api/customers', 'POST', { name: '客户 B', ipLimit: 1, deviceLimit: 1 })).customer;
const direct = (await request('/api/chains', 'POST', { name: 'IPv6 单机节点', topology: 'direct', networkMode: 'ipv6', relayServerIds: [relay.id], customerIds: [customerB.id], relayProtocol: 'shadowsocks-2022-aes256' })).chain;
const directDeploy = await request(`/api/chains/${direct.id}/deploy`, 'POST', {});
assert.equal(directDeploy.deployments.length, 1);
assert.equal(directDeploy.deployments[0].role, 'direct');
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);
for (let count = 0; count < 4; count += 1) {
const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } });
const { job } = await polled.json();
if (!job) break;
await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true, result:{ artifacts:{ realityPublicKey:'test-public-key' } } }) });
}
const usageReport = async (uplink, downlink, epoch = 'xray-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:[{ resourceId:directDeploy.deployments[0].resourceId, 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, 'xray-start-2'); // Xray restarted, and its counters began from zero.
await usageReport(250, 900, 'xray-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);
const meteredDeployment = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id);
assert.deepEqual(meteredDeployment.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, 'xray-start-3'); // A fast restart can exceed previous counters before polling.
await usageReport(1000, 2000, 'xray-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 originalUri = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].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/chains')).chains.find((item) => item.id === direct.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 }) });
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, 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/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id);
assert.equal(afterDelete.status, 'queued');
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/chains')).chains.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);
// Routes deleted by older versions can recover the original URI with one explicit action.
await request(`/api/chains/${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/chains')).chains.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/chains')).chains.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/chains/${direct.id}/restore`, '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 }) });
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, originalUri);
// A forward route restores both the exit and the entry with the same ports and client URI.
const customerC = (await request('/api/customers', 'POST', { name:'配额恢复测试', trafficLimitBytes:100 })).customer;
const forward = (await request('/api/chains', 'POST', { name:'保留节点', relayServerIds:[relay.id], exitServerId:exit.id,
customerIds:[customerC.id], relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128' })).chain;
const forwardDeploy = (await request(`/api/chains/${forward.id}/deploy`, 'POST', {})).deployments;
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;
};
await completeNext(agentKeys[exit.id]);
await completeNext(agentKey);
const forwardEntry = forwardDeploy.find((item) => item.role === 'relay');
const forwardUri = (await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.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:[{ resourceId:forwardEntry.resourceId, 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/chains')).chains.find((item) => item.id === forward.id).status, 'suspended');
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');
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.id).clientUri, forwardUri);
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 200);
// AnyTLS is a separate entry engine, but follows the same quota and restore 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 anyChain = (await request('/api/chains', 'POST', { name:'AnyTLS → VLESS', relayServerIds:[relay.id], exitServerId:exit.id,
customerIds:[anyCustomer.id], relayProtocol:'anytls', exitProtocol:'vless-tcp' })).chain;
const oldAgent = await fetch(`${base}/api/chains/${anyChain.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/chains/${anyChain.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'] } }) });
const anyDeploy = (await request(`/api/chains/${anyChain.id}/deploy`, 'POST', {})).deployments;
const anyExitJob = await completeNext(agentKeys[exit.id]);
assert.equal(anyExitJob.payload.resource.inbounds[0].protocol, 'vless');
const anyEntryJob = await completeNext(agentKey);
assert.equal(anyEntryJob.payload.resource.engine, 'sing-box');
assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless');
const anyEntry = anyDeploy.find((item) => item.role === 'relay');
const anyUri = (await request('/api/chains')).deployments.find((item) => item.id === anyEntry.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:[{ resourceId:anyEntry.resourceId, uplink:120, downlink:400, epoch:'sing-box:123:456' }] }) });
assert.equal(anyUsage.status, 200);
assert.deepEqual((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).meteredTraffic,
{ uplink:120, downlink:400 });
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 });
assert.equal((await completeNext(agentKey)).payload.resource.engine, 'sing-box');
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource');
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri, 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');
// A failed / not-yet-applied route can disappear from the UI immediately,
// while cleanup tombstones retain the ports until agents acknowledge removal.
await request(`/api/chains/${chain.id}/remove`, 'POST', {});
const deleted = await request(`/api/chains/${chain.id}`, 'DELETE');
assert.equal(deleted.cleanupPending, 2);
const afterChainDelete = await request('/api/chains');
assert.equal(afterChainDelete.chains.some((item) => item.id === chain.id), false);
assert.equal(afterChainDelete.deployments.some((item) => item.chainId === chain.id), false);
const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'));
assert.equal(source.deployments.filter((item) => item.chainId === chain.id && item.archived && item.status === 'removing').length, 2);
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);
});