Files
harbor-net/test/server/state-contract.test.js
Dmitriy Petrov 267afc5c7e
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
Introduce stable server IDs for subscription state
2026-07-12 11:59:22 +03:00

440 lines
18 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 '../../src/shared/contracts/state.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
import { HARBOR_VERSIONS } from '../../src/shared/versions.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 normalizes legacy storage and validates the canonical 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.deepEqual(snapshot.selection, {
desiredServerId: legacyServerId,
appliedServerId: legacyServerId,
});
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('GET and domain mutations return one state shape with monotonic revisions', 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 },
}],
};
const testServerId = createServerId(config.outbounds[0]);
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 delayedPath = '';
let invalidNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (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 === invalidNextPath) {
invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
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,
}));
const port = await freePort();
const child = spawn(process.execPath, ['src/server/index.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.selection.appliedServerId, 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 stateKeys = Object.keys(initial).sort();
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 preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
]) {
const failedImport = await rawRequest(
port,
'/api/subscription/fetch',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
);
assert.equal(failedImport.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
);
}
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
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 fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: testServerId,
appliedServerId: testServerId,
});
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
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, 3), [
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: testServerId },
]);
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);
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.payload.error.retryable, false);
});