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('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', 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); });