Persist operation state in server and reuse returned snapshots
This commit is contained in:
183
test/server/state-contract.test.js
Normal file
183
test/server/state-contract.test.js
Normal file
@@ -0,0 +1,183 @@
|
||||
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';
|
||||
|
||||
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 request(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();
|
||||
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 stored = normalizeStoredState({
|
||||
subscriptionUrl: 'https://provider.example/subscription/full-secret-token',
|
||||
selectedTag: ' legacy ',
|
||||
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
|
||||
});
|
||||
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: 'legacy',
|
||||
appliedServerId: 'legacy',
|
||||
});
|
||||
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 },
|
||||
}],
|
||||
};
|
||||
fs.mkdirSync(binDir);
|
||||
fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDir, 'sing-box'), 0o755);
|
||||
|
||||
const subscriptionServer = http.createServer((req, res) => {
|
||||
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/full-secret-token`;
|
||||
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'),
|
||||
},
|
||||
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);
|
||||
assertStateSnapshot(initial);
|
||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||
const stateKeys = Object.keys(initial).sort();
|
||||
let revision = initial.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;
|
||||
}
|
||||
|
||||
await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl });
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
|
||||
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
|
||||
assert.deepEqual(applied.state.selection, {
|
||||
desiredServerId: 'test-vpn',
|
||||
appliedServerId: 'test-vpn',
|
||||
});
|
||||
assert.equal(applied.state.connection.process, 'running');
|
||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||
await mutation('/api/subscription/refresh');
|
||||
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((await stateResponse('/api/servers/ping-all')).results, []);
|
||||
});
|
||||
Reference in New Issue
Block a user