654 lines
26 KiB
JavaScript
654 lines
26 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
assertStateSnapshot,
|
|
createStateSnapshot,
|
|
normalizeStoredState,
|
|
} from '../../dist/shared/contracts/state.js';
|
|
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
|
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
|
|
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
|
|
|
const root = path.resolve(import.meta.dirname, '../..');
|
|
|
|
function listen(server) {
|
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
|
|
}
|
|
|
|
function close(server) {
|
|
return new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
async function freePort() {
|
|
const server = http.createServer();
|
|
const port = await listen(server);
|
|
await close(server);
|
|
return port;
|
|
}
|
|
|
|
async function rawRequest(port, pathname, method = 'GET', body) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
|
method,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const payload = await response.json();
|
|
return { response, payload };
|
|
}
|
|
|
|
async function request(port, pathname, method = 'GET', body) {
|
|
const { response, payload } = await rawRequest(port, pathname, method, body);
|
|
assert.equal(response.ok, true, JSON.stringify(payload));
|
|
return payload;
|
|
}
|
|
|
|
async function waitForState(port, child, stderr) {
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
|
|
try {
|
|
return await request(port, '/api/state');
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
}
|
|
throw new Error(`Harbor did not start: ${stderr()}`);
|
|
}
|
|
|
|
test('state v1 projects legacy storage through the canonical profile snapshot', () => {
|
|
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
|
|
const legacyServerId = createServerId(legacyServer);
|
|
const stored = normalizeStoredState({
|
|
subscriptionUrl: 'https://provider.example/subscription/test',
|
|
selectedTag: ' legacy ',
|
|
servers: [legacyServer],
|
|
});
|
|
const snapshot = createStateSnapshot({
|
|
storedState: stored,
|
|
runtime: { running: true, startedAt: '2026-07-11T10:00:00.000Z' },
|
|
gatewayAuto: null,
|
|
appMode: 'gateway',
|
|
configExists: true,
|
|
subscriptionHost: 'provider.example/…',
|
|
now: new Date('2026-07-11T12:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(snapshot.apiVersion, 1);
|
|
assert.equal(snapshot.profiles.length, 1);
|
|
assert.equal(snapshot.profiles[0].id, 'profile_primary');
|
|
assert.equal(snapshot.profiles[0].desiredServerId, legacyServerId);
|
|
assert.deepEqual(snapshot.selection, {
|
|
desiredProfileId: 'profile_primary',
|
|
desiredServerId: legacyServerId,
|
|
appliedProfileId: 'profile_primary',
|
|
appliedServerId: legacyServerId,
|
|
appliedServerSnapshot: snapshot.profiles[0].servers[0],
|
|
});
|
|
assert.equal(snapshot.connection.process, 'running');
|
|
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
|
|
assert.throws(
|
|
() => assertStateSnapshot({ ...snapshot, revision: -1 }),
|
|
/Invalid Harbor state snapshot v1/,
|
|
);
|
|
});
|
|
|
|
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
|
const port = await freePort();
|
|
const subscriptionUrl = 'https://provider.example/disabled';
|
|
const rejectedServer = {
|
|
type: 'vless',
|
|
tag: '🚫 Subscription disabled',
|
|
server: '0.0.0.0',
|
|
server_port: 1,
|
|
};
|
|
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
|
|
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
|
|
subscriptionUrl,
|
|
selectedTag: rejectedServer.tag,
|
|
servers: [rejectedServer],
|
|
routeRules,
|
|
}));
|
|
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
|
|
url: subscriptionUrl,
|
|
config: { outbounds: [rejectedServer] },
|
|
}));
|
|
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
|
|
|
|
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
APP_MODE: 'client',
|
|
DATA_DIR: dir,
|
|
PORT: String(port),
|
|
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
|
|
},
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
let stderr = '';
|
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
t.after(async () => {
|
|
child.kill('SIGTERM');
|
|
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const state = await waitForState(port, child, () => stderr);
|
|
assert.equal(state.subscription.status, 'missing');
|
|
assert.equal(state.hasSubscription, false);
|
|
assert.deepEqual(state.servers, []);
|
|
assert.ok(state.route.localRules.some((rule) => (
|
|
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
|
|
)));
|
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
|
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
|
|
assert.equal(child.exitCode, null);
|
|
});
|
|
|
|
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
|
|
const binDir = path.join(dir, 'bin');
|
|
const config = {
|
|
outbounds: [{
|
|
type: 'vless',
|
|
tag: 'test-vpn',
|
|
server: 'vpn.example.test',
|
|
server_port: 443,
|
|
uuid: '00000000-0000-4000-8000-000000000000',
|
|
tls: { enabled: true, server_name: 'edge.example.test' },
|
|
transport: { type: 'ws', path: '/test', headers: { Host: 'edge.example.test' } },
|
|
}],
|
|
};
|
|
const legacyServerId = createServerId(config.outbounds[0]);
|
|
const testServerId = normalizeSubscriptionConfig(config).servers[0].id;
|
|
assert.notEqual(testServerId, legacyServerId);
|
|
fs.mkdirSync(binDir);
|
|
const singboxPath = path.join(binDir, 'sing-box');
|
|
const workingSingbox = `#!/usr/bin/env node
|
|
if (process.argv[2] === 'check') process.exit(0);
|
|
if (process.argv[2] === 'version') {
|
|
console.log('sing-box version 1.12.13');
|
|
process.exit(0);
|
|
}
|
|
process.on('SIGTERM', () => process.exit(0));
|
|
setInterval(() => {}, 60_000);
|
|
`;
|
|
fs.writeFileSync(singboxPath, workingSingbox);
|
|
fs.chmodSync(singboxPath, 0o755);
|
|
|
|
let providerFetchCount = 0;
|
|
let invalidNextPath = '';
|
|
let trafficExhaustedNextPath = '';
|
|
const subscriptionServer = http.createServer((req, res) => {
|
|
providerFetchCount += 1;
|
|
if (req.url === '/timeout') return;
|
|
if (req.url === '/unavailable') {
|
|
res.writeHead(503);
|
|
return res.end('unavailable');
|
|
}
|
|
if (req.url === '/invalid') {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
return res.end('not a subscription');
|
|
}
|
|
if (req.url === '/traffic' || req.url === trafficExhaustedNextPath) {
|
|
trafficExhaustedNextPath = '';
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json',
|
|
'subscription-userinfo': 'upload=60; download=40; total=100; expire=4102444800',
|
|
});
|
|
return res.end(JSON.stringify({
|
|
outbounds: [{
|
|
type: 'vless',
|
|
tag: 'Account unavailable',
|
|
server: '0.0.0.0',
|
|
server_port: 1,
|
|
}],
|
|
}));
|
|
}
|
|
if (req.url === '/expired') {
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json',
|
|
'subscription-userinfo': 'upload=10; download=20; total=100; expire=1',
|
|
});
|
|
return res.end(JSON.stringify(config));
|
|
}
|
|
if (req.url === '/disabled') {
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json',
|
|
'subscription-userinfo': 'upload=0; download=0; total=100; expire=4102444800',
|
|
});
|
|
return res.end(JSON.stringify({
|
|
outbounds: [{
|
|
type: 'vless',
|
|
tag: '🚫 Subscription disabled',
|
|
server: '0.0.0.0',
|
|
server_port: 1,
|
|
}],
|
|
}));
|
|
}
|
|
if (req.url === invalidNextPath) {
|
|
invalidNextPath = '';
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
return res.end('not a subscription');
|
|
}
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json',
|
|
'subscription-userinfo': 'upload=10; download=20; total=100',
|
|
});
|
|
res.end(JSON.stringify(config));
|
|
});
|
|
const subscriptionPort = await listen(subscriptionServer);
|
|
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/test`;
|
|
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
|
|
subscriptionUrl,
|
|
selectedTag: 'test-vpn',
|
|
servers: [{ tag: 'test-vpn', type: 'vless', server: 'vpn.example.test', server_port: 443 }],
|
|
}));
|
|
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
|
|
url: subscriptionUrl,
|
|
config,
|
|
}));
|
|
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
|
|
|
|
const port = await freePort();
|
|
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
APP_MODE: 'client',
|
|
DATA_DIR: dir,
|
|
PORT: String(port),
|
|
PATH: `${binDir}:${process.env.PATH}`,
|
|
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
|
|
SUBSCRIPTION_TIMEOUT_MS: '50',
|
|
},
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
let stderr = '';
|
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
t.after(async () => {
|
|
child.kill('SIGTERM');
|
|
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
|
|
await close(subscriptionServer);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const initial = await waitForState(port, child, () => stderr);
|
|
const version = await request(port, '/api/version');
|
|
assert.deepEqual(version, {
|
|
apiVersion: 1,
|
|
location: 'mac',
|
|
components: { macClient: HARBOR_VERSIONS.macClient },
|
|
runtime: { singBox: '1.12.13' },
|
|
});
|
|
assertStateSnapshot(initial);
|
|
assert.equal(initial.profiles.length, 1);
|
|
assert.equal(initial.profiles[0].id, 'profile_primary');
|
|
assert.equal(initial.profiles[0].label, 'Основной');
|
|
assert.equal(initial.selection.desiredProfileId, 'profile_primary');
|
|
assert.equal(initial.selection.appliedServerId, testServerId);
|
|
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
|
|
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
|
|
assert.deepEqual(initial.route.localRules, [
|
|
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
|
]);
|
|
assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
|
|
assert.equal(initial.route.localRulesRevision, 0);
|
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
|
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
|
assert.equal(migratedState.schemaVersion, 5);
|
|
assert.equal(migratedState.profiles.length, 1);
|
|
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
|
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
|
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
|
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
|
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
|
|
const stateKeys = Object.keys(initial).sort();
|
|
assert.deepEqual(stateKeys, [
|
|
'apiVersion',
|
|
'configExists',
|
|
'connection',
|
|
'fetchedAt',
|
|
'gatewayAuto',
|
|
'generatedAt',
|
|
'hasSubscription',
|
|
'mode',
|
|
'operation',
|
|
'port',
|
|
'profiles',
|
|
'proxyPort',
|
|
'revision',
|
|
'route',
|
|
'selectedTag',
|
|
'selection',
|
|
'servers',
|
|
'singboxRunning',
|
|
'singboxStartedAt',
|
|
'subscription',
|
|
'subscriptionHost',
|
|
'userInfo',
|
|
]);
|
|
let revision = initial.revision;
|
|
let rulesRevision = initial.route.localRulesRevision;
|
|
|
|
const invalidSubscription = await rawRequest(
|
|
port,
|
|
'/api/subscription/validate',
|
|
'POST',
|
|
{ url: 'not-a-url' },
|
|
);
|
|
assert.equal(invalidSubscription.response.status, 400);
|
|
assert.deepEqual(
|
|
{
|
|
code: invalidSubscription.payload.error.code,
|
|
retryable: invalidSubscription.payload.error.retryable,
|
|
},
|
|
{ code: 'SUBSCRIPTION_INVALID', retryable: false },
|
|
);
|
|
assert.equal(typeof invalidSubscription.payload.error.correlationId, 'string');
|
|
|
|
const providerUnavailable = await rawRequest(
|
|
port,
|
|
'/api/subscription/validate',
|
|
'POST',
|
|
{ url: `http://127.0.0.1:${subscriptionPort}/unavailable` },
|
|
);
|
|
assert.equal(providerUnavailable.response.status, 502);
|
|
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
|
|
assert.equal(providerUnavailable.payload.error.retryable, true);
|
|
|
|
const primaryProfileId = initial.profiles[0].id;
|
|
const preservedPrimary = structuredClone(
|
|
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
|
|
);
|
|
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
|
for (const [pathname, expectedCode] of [
|
|
['/timeout', 'PROVIDER_UNAVAILABLE'],
|
|
['/invalid', 'SUBSCRIPTION_INVALID'],
|
|
['/expired', 'SUBSCRIPTION_EXPIRED'],
|
|
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
|
|
['/disabled', 'SUBSCRIPTION_DISABLED'],
|
|
]) {
|
|
const failedAdd = await rawRequest(
|
|
port,
|
|
'/api/profiles',
|
|
'POST',
|
|
{
|
|
label: `Новая ${pathname}`,
|
|
url: `http://127.0.0.1:${subscriptionPort}${pathname}`,
|
|
expectedRevision: revision,
|
|
},
|
|
);
|
|
assert.equal(failedAdd.payload.error.code, expectedCode);
|
|
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
|
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
|
preservedConfig,
|
|
);
|
|
revision = (await request(port, '/api/state')).revision;
|
|
}
|
|
|
|
trafficExhaustedNextPath = '/subscription/test';
|
|
const exhaustedRefresh = await rawRequest(
|
|
port,
|
|
`/api/profiles/${primaryProfileId}/refresh`,
|
|
'POST',
|
|
{ expectedRevision: revision },
|
|
);
|
|
assert.equal(exhaustedRefresh.response.status, 400);
|
|
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
|
|
const stateAfterExhaustedRefresh = await request(port, '/api/state');
|
|
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
|
|
assert.equal(stateAfterExhaustedRefresh.profiles[0].subscription.status, 'stale');
|
|
assert.equal(
|
|
stateAfterExhaustedRefresh.profiles[0].subscription.errorCode,
|
|
'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
|
|
);
|
|
assert.equal(stateAfterExhaustedRefresh.profiles[0].servers[0].id, testServerId);
|
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedConfig);
|
|
revision = stateAfterExhaustedRefresh.revision;
|
|
|
|
const missingServer = await rawRequest(
|
|
port,
|
|
'/api/apply',
|
|
'POST',
|
|
{ profileId: primaryProfileId, serverId: 'missing-server', expectedRevision: revision },
|
|
);
|
|
assert.equal(missingServer.response.status, 404);
|
|
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
|
|
const stateAfterMissingServer = await request(port, '/api/state');
|
|
assert.equal(stateAfterMissingServer.selection.desiredProfileId, primaryProfileId);
|
|
assert.equal(stateAfterMissingServer.selection.desiredServerId, testServerId);
|
|
revision = stateAfterMissingServer.revision;
|
|
|
|
async function stateResponse(pathname, method = 'POST', body) {
|
|
const result = await request(port, pathname, method, body);
|
|
assert.deepEqual(Object.keys(result.state).sort(), stateKeys);
|
|
assertStateSnapshot(result.state);
|
|
return result;
|
|
}
|
|
|
|
async function mutation(pathname, method = 'POST', body) {
|
|
const result = await stateResponse(pathname, method, body);
|
|
assert.ok(result.state.revision > revision, `${pathname} did not increase revision`);
|
|
revision = result.state.revision;
|
|
return result;
|
|
}
|
|
|
|
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
|
|
const fetchesBeforeDuplicate = providerFetchCount;
|
|
const duplicateProfile = await rawRequest(port, '/api/profiles', 'POST', {
|
|
label: 'основной',
|
|
url: replacementUrl,
|
|
expectedRevision: revision,
|
|
});
|
|
assert.equal(duplicateProfile.response.status, 409);
|
|
assert.equal(duplicateProfile.payload.error.code, 'PROFILE_NAME_CONFLICT');
|
|
assert.equal(providerFetchCount, fetchesBeforeDuplicate);
|
|
assert.equal((await request(port, '/api/state')).revision, revision);
|
|
|
|
const fetchesBeforeAdd = providerFetchCount;
|
|
const added = await mutation('/api/profiles', 'POST', {
|
|
label: 'Работа',
|
|
url: replacementUrl,
|
|
expectedRevision: revision,
|
|
});
|
|
const workProfileId = added.profileId;
|
|
assert.equal(providerFetchCount, fetchesBeforeAdd + 1);
|
|
assert.equal(added.state.profiles.length, 2);
|
|
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
|
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
|
|
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
|
testServerId,
|
|
);
|
|
|
|
const renamed = await mutation(`/api/profiles/${workProfileId}`, 'PATCH', {
|
|
label: 'Офис',
|
|
expectedRevision: revision,
|
|
});
|
|
assert.equal(renamed.state.profiles.find(({ id }) => id === workProfileId).label, 'Офис');
|
|
const selected = await mutation(`/api/profiles/${workProfileId}/server`, 'PUT', {
|
|
serverId: testServerId,
|
|
expectedRevision: revision,
|
|
});
|
|
assert.equal(
|
|
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
|
|
testServerId,
|
|
);
|
|
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
|
|
|
|
const applied = await mutation('/api/apply', 'POST', {
|
|
profileId: workProfileId,
|
|
serverId: testServerId,
|
|
expectedRevision: revision,
|
|
});
|
|
assert.equal(applied.state.selection.desiredProfileId, workProfileId);
|
|
assert.equal(applied.state.selection.desiredServerId, testServerId);
|
|
assert.equal(applied.state.selection.appliedProfileId, workProfileId);
|
|
assert.equal(applied.state.selection.appliedServerId, testServerId);
|
|
assert.equal(applied.state.selection.appliedServerSnapshot.id, testServerId);
|
|
assert.equal(applied.state.connection.process, 'running');
|
|
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
|
invalidNextPath = '/subscription/replacement';
|
|
const failedRefresh = await rawRequest(
|
|
port,
|
|
`/api/profiles/${workProfileId}/refresh`,
|
|
'POST',
|
|
{ expectedRevision: revision },
|
|
);
|
|
assert.equal(failedRefresh.response.status, 400);
|
|
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
|
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
|
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
|
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
|
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
|
assert.equal(staleWorkProfile.servers[0].id, testServerId);
|
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
|
|
revision = (await request(port, '/api/state')).revision;
|
|
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
|
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
|
|
|
let routed = await mutation('/api/route-rules', 'PUT', {
|
|
expectedRulesRevision: rulesRevision,
|
|
rules: [
|
|
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
|
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
|
|
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
|
|
],
|
|
});
|
|
rulesRevision = routed.state.route.localRulesRevision;
|
|
assert.deepEqual(routed.state.route.localRules, [
|
|
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
|
{ type: 'domain', value: 'example.com', enabled: true },
|
|
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
|
]);
|
|
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
|
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 4), [
|
|
{
|
|
inbound: ['mixed-in', 'diagnostics-vpn-in'],
|
|
action: 'sniff',
|
|
sniffer: ['http', 'tls', 'quic'],
|
|
timeout: '1s',
|
|
},
|
|
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
|
{ domain: ['example.com'], outbound: 'direct' },
|
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
|
]);
|
|
|
|
await mutation('/api/singbox/stop');
|
|
routed = await mutation('/api/route-rules', 'PUT', {
|
|
expectedRulesRevision: rulesRevision,
|
|
rules: [
|
|
...routed.state.route.localRules,
|
|
{ type: 'domain_keyword', value: 'media', enabled: true },
|
|
],
|
|
});
|
|
rulesRevision = routed.state.route.localRulesRevision;
|
|
assert.equal(routed.state.connection.process, 'stopped');
|
|
assert.equal(routed.state.route.localRulesPendingRestart, true);
|
|
assert.deepEqual(routed.state.route.activeLocalRules, []);
|
|
const restartedRules = await mutation('/api/singbox/restart');
|
|
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
|
|
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules);
|
|
|
|
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
|
expectedRulesRevision: rulesRevision,
|
|
rules: [{ type: 'domain_regex', value: '.*' }],
|
|
});
|
|
assert.equal(invalidRules.response.status, 400);
|
|
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
|
|
assert.equal((await request(port, '/api/state')).revision, revision);
|
|
|
|
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
|
expectedRulesRevision: 0,
|
|
rules: [],
|
|
});
|
|
assert.equal(staleRules.response.status, 409);
|
|
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
|
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
|
|
|
|
const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
|
|
expectedRevision: revision,
|
|
rules: routed.state.route.localRules,
|
|
});
|
|
assert.equal(legacyNoop.response.status, 200);
|
|
|
|
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
|
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
|
const fs = require('node:fs');
|
|
if (process.argv[2] === 'check') {
|
|
const config = fs.readFileSync(process.argv[4], 'utf8');
|
|
process.exit(config.includes('broken.example') ? 1 : 0);
|
|
}
|
|
if (process.argv[2] === 'version') process.exit(0);
|
|
process.on('SIGTERM', () => process.exit(0));
|
|
setInterval(() => {}, 60_000);
|
|
`);
|
|
fs.chmodSync(singboxPath, 0o755);
|
|
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
|
expectedRulesRevision: rulesRevision,
|
|
rules: [{ type: 'domain', value: 'broken.example' }],
|
|
});
|
|
assert.equal(failedRules.response.status, 422);
|
|
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
|
|
const rolledBack = await request(port, '/api/state');
|
|
assert.deepEqual(rolledBack.route.localRules, routed.state.route.localRules);
|
|
assert.equal(rolledBack.connection.process, 'running');
|
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
|
|
revision = rolledBack.revision;
|
|
fs.writeFileSync(singboxPath, workingSingbox);
|
|
fs.chmodSync(singboxPath, 0o755);
|
|
|
|
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
|
if (process.argv[2] === 'check') {
|
|
require('node:fs').unlinkSync(process.argv[1]);
|
|
process.exit(0);
|
|
}
|
|
`);
|
|
fs.chmodSync(singboxPath, 0o755);
|
|
const processFailure = await rawRequest(port, '/api/singbox/restart', 'POST');
|
|
assert.equal(processFailure.response.status, 503);
|
|
assert.equal(processFailure.payload.error.code, 'PROCESS_START_FAILED');
|
|
assert.equal(processFailure.payload.error.retryable, true);
|
|
fs.writeFileSync(singboxPath, workingSingbox);
|
|
fs.chmodSync(singboxPath, 0o755);
|
|
|
|
revision = (await request(port, '/api/state')).revision;
|
|
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
|
|
const inactiveDeleted = await mutation(`/api/profiles/${primaryProfileId}`, 'DELETE', {
|
|
mode: 'delete',
|
|
expectedRevision: revision,
|
|
});
|
|
assert.deepEqual(inactiveDeleted.state.profiles.map(({ id }) => id), [workProfileId]);
|
|
assert.equal(inactiveDeleted.state.selection.appliedProfileId, workProfileId);
|
|
|
|
const activeDeleted = await mutation(`/api/profiles/${workProfileId}`, 'DELETE', {
|
|
mode: 'stop-and-delete',
|
|
expectedRevision: revision,
|
|
});
|
|
assert.equal(activeDeleted.state.subscription.status, 'missing');
|
|
assert.deepEqual(activeDeleted.state.profiles, []);
|
|
assert.equal(activeDeleted.state.selection.desiredProfileId, '');
|
|
assert.equal(activeDeleted.state.selection.appliedProfileId, '');
|
|
assert.equal(activeDeleted.state.servers.length, 0);
|
|
assert.deepEqual(activeDeleted.state.route.localRules, routed.state.route.localRules);
|
|
|
|
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
|
assert.equal(missingConfig.response.status, 404);
|
|
assert.equal(missingConfig.payload.error.code, 'PROFILE_NOT_FOUND');
|
|
assert.equal(missingConfig.payload.error.retryable, false);
|
|
});
|