import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { createGatewayAutoService, } from '../../dist/server/features/routing/index.js'; import { applyGatewayPreference, createGatewayAutoState, nextGatewayAutoState, sameGatewayRoute, } from '../../dist/server/gatewayPresence.js'; import { createGatewayAutoRoute } from '../../dist/server/http/routes/gatewayAutoRoute.js'; const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef'; const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' }; const storedProfile = (servers = [server]) => ({ id: 'primary', label: 'Основной', subscriptionUrl, subscriptionConfig: { outbounds: [] }, servers, userInfo: {}, fetchedAt: null, desiredServerId: servers[0]?.id || '', lastRefreshAttemptAt: null, lastRefreshErrorCode: null, }); const canonicalState = (profiles = [storedProfile()]) => ({ revision: 10, profiles, desiredProfileId: profiles[0]?.id || '', appliedProfileId: profiles[0]?.id || '', appliedServerId: profiles[0]?.desiredServerId || '', appliedServerSnapshot: profiles[0]?.servers[0] || null, routeRules: [], appliedRouteRules: [], routeRulesRevision: 0, gatewayAutoEnabled: true, }); const networkA = { gateway: '192.168.50.111', interface: 'en0', mac: 'aa:bb:cc:dd:ee:ff', observedAt: Date.now(), }; const networkB = { gateway: '192.168.60.111', interface: 'en1', mac: '11:22:33:44:55:66', observedAt: Date.now(), }; const verifiedA = { gatewayId: 'gateway-a', uiOrigin: 'http://192.168.50.111:3456', verifiedAt: '2026-08-08T12:00:00.000Z', }; function directState(network = networkA, verified = verifiedA) { return nextGatewayAutoState(createGatewayAutoState(), { network, verifiedGateway: verified, }); } function deferred() { let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function createHarness(overrides = {}) { let state = structuredClone(overrides.state ?? canonicalState()); let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config'; let network = Object.hasOwn(overrides, 'network') ? overrides.network : networkA; let tail = Promise.resolve(); let active = 0; let peak = 0; let stateUpdateIndex = 0; let timerCallback = null; let timerUnrefs = 0; let timerClears = 0; const events = []; const warnings = []; const failures = { stateUpdates: [...(overrides.failures?.stateUpdates || [])], ...overrides.failures, }; const serialize = (operation) => { const run = async () => { active += 1; peak = Math.max(peak, active); try { return await operation(); } finally { active -= 1; } }; const result = tail.then(run, run); tail = result.then(() => undefined, () => undefined); return result; }; const service = createGatewayAutoService({ appMode: overrides.appMode || 'client', state: { read: () => structuredClone(state), update: (mutator) => { const failure = failures.stateUpdates[stateUpdateIndex++]; if (failure instanceof Error) throw failure; state = { ...structuredClone(mutator(structuredClone(state))), revision: state.revision + 1, }; events.push('state.update'); if (failure?.after) throw failure.after; return structuredClone(state); }, }, subscription: { readConfig: () => overrides.missingSubscription ? null : { outbounds: [] }, }, config: { build: (_subscription, selectedServerId, routeRules, gatewayAuto) => { if (failures.configBuild) throw failures.configBuild; return { selectedServerId, routeRules, clientDirect: gatewayAuto.mode === 'gateway-direct', }; }, read: () => config, write: (value) => { events.push('config.write'); if (failures.configWrite) throw failures.configWrite; config = JSON.stringify(value); if (failures.configWriteAfter) throw failures.configWriteAfter; }, restore: (value) => { events.push('config.restore'); if (failures.configRestore) throw failures.configRestore; config = value; }, remove: () => { events.push('config.remove'); if (failures.configRemove) throw failures.configRemove; config = null; }, }, runtime: { isRunning: () => overrides.running ?? true, applyCommand: async () => { events.push('runtime.apply'); return typeof overrides.commandResult === 'function' ? overrides.commandResult() : overrides.commandResult || { ok: true, mutationStarted: true }; }, restoreRunning: async () => { events.push('runtime.restore'); if (failures.runtimeRestore) throw failures.runtimeRestore; }, stopCommand: async () => { events.push('runtime.stop'); return overrides.stopCommandResult || { ok: true, mutationStarted: true }; }, }, discovery: { readHostNetwork: () => structuredClone(network), probeGateway: async (input) => { events.push('discovery.probe'); if (overrides.probe) return overrides.probe(input); return verifiedA; }, }, transition: { createInitial: createGatewayAutoState, applyPreference: applyGatewayPreference, next: nextGatewayAutoState, sameRoute: sameGatewayRoute, }, serialize, scheduler: { setInterval: (callback, intervalMs) => { events.push(`timer.set:${intervalMs}`); timerCallback = callback; return { unref: () => { timerUnrefs += 1; } }; }, clearInterval: () => { events.push('timer.clear'); timerClears += 1; timerCallback = null; }, }, onRouteChange: (gatewayAuto) => events.push(`route:${gatewayAuto.mode}`), onDiscoveryWarning: (reason) => warnings.push(reason), onTimerError: (error) => warnings.push(error.message), }); if (overrides.gatewayAuto) service.set(structuredClone(overrides.gatewayAuto)); return { service, events, warnings, enqueue: serialize, setNetwork: (value) => { network = value; }, setState: (value) => { state = structuredClone(value); }, patchState: (value) => { state = { ...state, ...structuredClone(value) }; }, fireTimer: () => timerCallback?.(), snapshot: () => structuredClone({ state, gatewayAuto: service.read(), config, peak, timerUnrefs, timerClears, timerActive: Boolean(timerCallback), }), }; } function withoutRevision(value) { const copy = structuredClone(value); delete copy.state.revision; delete copy.peak; return copy; } test('gateway-auto handles no-op and metadata-only discovery without config/runtime mutation', async () => { const noOp = createHarness({ appMode: 'gateway' }); const before = noOp.snapshot(); await noOp.service.refresh(); assert.deepEqual(noOp.snapshot(), before); assert.deepEqual(noOp.events, []); const metadata = createHarness({ probe: async () => { throw new Error('offline'); } }); await metadata.service.refresh(); assert.equal(metadata.snapshot().gatewayAuto.mode, 'local-vpn'); assert.equal(metadata.snapshot().gatewayAuto.failures, 1); assert.deepEqual(metadata.events, ['discovery.probe', 'state.update']); assert.deepEqual(metadata.warnings, ['offline']); }); test('gateway-auto mode changes separate state-only, stopped and running paths', async () => { const stateOnly = createHarness({ gatewayAuto: directState(), state: { ...canonicalState([]), revision: 1 }, running: false, }); await stateOnly.service.setEnabled(false); assert.equal(stateOnly.snapshot().gatewayAuto.mode, 'local-vpn'); assert.deepEqual(stateOnly.events, ['state.update', 'state.update', 'route:local-vpn']); const stopped = createHarness({ gatewayAuto: directState(), running: false }); await stopped.service.setEnabled(false); assert.equal(stopped.events.includes('config.write'), true); assert.equal(stopped.events.includes('runtime.apply'), false); const running = createHarness({ gatewayAuto: directState(), running: true }); await running.service.setEnabled(false); assert.deepEqual(running.events.slice(0, 4), [ 'config.write', 'runtime.apply', 'state.update', 'state.update', ]); }); test('gateway-auto startup writes candidate config before publication without applying runtime', async () => { const harness = createHarness(); await harness.service.refresh({ reconfigure: false }); assert.equal(harness.snapshot().gatewayAuto.mode, 'gateway-direct'); assert.deepEqual(harness.events, [ 'discovery.probe', 'config.write', 'state.update', 'route:gateway-direct', ]); }); test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => { const state = canonicalState(); state.profiles[0].servers = []; state.profiles[0].desiredServerId = ''; const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' }); const harness = createHarness({ state, gatewayAuto: directState(), network: networkB, failures: { configBuild: buildError }, }); await assert.rejects(harness.service.refresh(), (error) => error === buildError); const after = harness.snapshot(); assert.equal(after.gatewayAuto.mode, 'local-vpn'); assert.equal(after.state.connectionDesired, 'stopped'); assert.equal(after.state.appliedProfileId, ''); assert.equal(after.state.appliedServerId, ''); assert.equal(harness.events.includes('runtime.stop'), true); }); test('Gateway discovery preserves a stale applied local runtime when direct config cannot build', async () => { const state = canonicalState(); state.profiles[0].servers = []; state.profiles[0].desiredServerId = ''; const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' }); const harness = createHarness({ state, failures: { configBuild: buildError } }); const before = harness.snapshot(); await assert.rejects(harness.service.refresh(), (error) => error === buildError); assert.deepEqual(withoutRevision(harness.snapshot()), withoutRevision(before)); assert.equal(harness.events.includes('runtime.stop'), false); }); test('disable and re-enable preserve verified Gateway identity and persisted preference commits', async () => { const harness = createHarness({ gatewayAuto: directState() }); await harness.service.setEnabled(false); const disabled = harness.snapshot(); assert.equal(disabled.gatewayAuto.mode, 'local-vpn'); assert.equal(disabled.gatewayAuto.gatewayId, verifiedA.gatewayId); assert.equal(disabled.state.gatewayAutoEnabled, false); await harness.service.setEnabled(true); const enabled = harness.snapshot(); assert.equal(enabled.gatewayAuto.mode, 'gateway-direct'); assert.equal(enabled.gatewayAuto.gatewayId, verifiedA.gatewayId); assert.equal(enabled.state.gatewayAutoEnabled, true); const revision = enabled.state.revision; await harness.service.setEnabled(true); assert.equal(harness.snapshot().state.revision, revision + 1); }); test('gateway-auto rollback restores config, transient owner, runtime and domain state', async () => { const original = new Error('state failed'); const harness = createHarness({ gatewayAuto: directState(), failures: { stateUpdates: [{ after: original }] }, }); const before = harness.snapshot(); await assert.rejects(harness.service.setEnabled(false), (error) => error === original); const after = harness.snapshot(); assert.deepEqual(withoutRevision(after), withoutRevision(before)); assert.ok(after.state.revision >= before.state.revision); assert.deepEqual(harness.events, [ 'config.write', 'runtime.apply', 'state.update', 'config.restore', 'runtime.restore', 'state.update', ]); }); test('gateway-auto rollback honors explicit runtime mutation phase and write-after-failure', async () => { const configFailure = new Error('config write after'); const config = createHarness({ gatewayAuto: directState(), failures: { configWriteAfter: configFailure }, }); await assert.rejects(config.service.setEnabled(false), (error) => error === configFailure); assert.equal(config.events.includes('config.restore'), true); assert.equal(config.events.includes('runtime.apply'), false); const preMutationError = new Error('invalid config'); const local = createHarness({ gatewayAuto: directState(), commandResult: { ok: false, mutationStarted: false, error: preMutationError }, }); await assert.rejects(local.service.setEnabled(false), (error) => error === preMutationError); assert.equal(local.events.includes('runtime.restore'), false); const postMutationError = new Error('remote failed'); const remote = createHarness({ gatewayAuto: directState(), commandResult: { ok: false, mutationStarted: true, error: postMutationError }, }); await assert.rejects(remote.service.setEnabled(false), (error) => error === postMutationError); assert.equal(remote.events.includes('runtime.restore'), true); }); test('gateway-auto rollback continues after restore failures and classifies runtime restore', async () => { const original = new Error('state failed'); const configRestore = new Error('config restore failed'); const aggregate = createHarness({ gatewayAuto: directState(), failures: { stateUpdates: [original], configRestore }, }); await assert.rejects(aggregate.service.setEnabled(false), (error) => { assert.ok(error instanceof AggregateError); assert.equal(error.message, 'Gateway auto rollback failed'); assert.deepEqual(error.errors, [original, configRestore]); return true; }); assert.equal(aggregate.events.includes('runtime.restore'), true); assert.ok(aggregate.events.filter((event) => event === 'state.update').length >= 1); const runtimeRestore = new Error('runtime restore failed'); const brokenRuntime = createHarness({ gatewayAuto: directState(), failures: { stateUpdates: [original], runtimeRestore }, }); await assert.rejects(brokenRuntime.service.setEnabled(false), (error) => { assert.equal(error.code, 'PROCESS_START_FAILED'); assert.deepEqual(error.cause.errors, [original, runtimeRestore]); return true; }); }); test('gateway-auto refresh coalesces while queued and never overlaps the shared serializer', async () => { const blocker = deferred(); const probe = deferred(); const harness = createHarness({ probe: () => probe.promise }); const queued = harness.enqueue(() => blocker.promise); const first = harness.service.refresh(); const second = harness.service.refresh(); assert.strictEqual(first, second); blocker.resolve(); await queued; await new Promise((resolve) => setImmediate(resolve)); probe.resolve(verifiedA); await first; assert.equal(harness.snapshot().peak, 1); }); test('gateway-auto discards stale subscription and route probe results', async () => { const gate = deferred(); const staleSubscription = createHarness({ probe: () => gate.promise }); staleSubscription.service.set(directState()); const refresh = staleSubscription.service.refresh(); await new Promise((resolve) => setImmediate(resolve)); const changedState = staleSubscription.snapshot().state; changedState.profiles[0].subscriptionUrl = `${subscriptionUrl}-new`; staleSubscription.setState(changedState); gate.resolve(verifiedA); await refresh; assert.deepEqual(staleSubscription.snapshot().gatewayAuto, createGatewayAutoState()); const routeGate = deferred(); const staleRoute = createHarness({ probe: () => routeGate.promise }); const routeRefresh = staleRoute.service.refresh(); await new Promise((resolve) => setImmediate(resolve)); staleRoute.setNetwork(networkB); routeGate.resolve(verifiedA); await routeRefresh; assert.deepEqual(staleRoute.snapshot().gatewayAuto, createGatewayAutoState()); }); test('gateway-auto demotes a changed route before probing and retains verified identity on transient failure', async () => { let routeHarness; routeHarness = createHarness({ gatewayAuto: directState(networkA), network: networkB, probe: async () => { assert.equal(routeHarness.service.read().mode, 'local-vpn'); assert.equal(routeHarness.service.read().gatewayId, ''); return { ...verifiedA, gatewayId: 'gateway-b', uiOrigin: 'http://192.168.60.111:3456' }; }, }); await routeHarness.service.refresh(); assert.equal(routeHarness.snapshot().gatewayAuto.gatewayId, 'gateway-b'); assert.deepEqual( routeHarness.events.filter((event) => event.startsWith('route:')), ['route:local-vpn', 'route:gateway-direct'], ); const transient = createHarness({ gatewayAuto: directState(), probe: async () => { throw new Error('timeout'); }, }); await transient.service.refresh(); const state = transient.snapshot().gatewayAuto; assert.equal(state.mode, 'gateway-direct'); assert.equal(state.gatewayId, verifiedA.gatewayId); assert.equal(state.failures, 1); assert.equal(state.lastError, 'timeout'); const structural = createHarness({ probe: async () => { throw { message: 'cross-realm failure' }; }, }); await structural.service.refresh(); assert.equal(structural.snapshot().gatewayAuto.lastError, 'cross-realm failure'); assert.deepEqual(structural.warnings, ['cross-realm failure']); }); test('gateway-auto discovery timer is single, unrefed, reports errors and stops idempotently', async () => { const harness = createHarness({ probe: async () => { throw new Error('timer failure'); } }); harness.service.startDiscovery(5_000); harness.service.startDiscovery(9_000); assert.deepEqual(harness.events, ['timer.set:5000']); assert.equal(harness.snapshot().timerUnrefs, 1); harness.fireTimer(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(harness.warnings, ['timer failure']); harness.service.stopDiscovery(); harness.service.stopDiscovery(); assert.equal(harness.snapshot().timerClears, 1); assert.equal(harness.snapshot().timerActive, false); }); test('gateway-auto route preserves validation, operation and exact response envelope', async () => { const calls = []; let body = { enabled: false }; const route = createGatewayAutoRoute({ appMode: 'client', gatewayAuto: { setEnabled: async (enabled) => { calls.push(['enabled', enabled]); } }, readBody: async () => body, withOperation: async (kind, operation) => { calls.push(['operation', kind]); return operation(); }, readStatePayload: async () => ({ gatewayAuto: { mode: 'local-vpn' }, revision: 7 }), }); const response = { writeHead: (status, headers) => { response.status = status; response.headers = headers; }, end: (payload) => { response.payload = JSON.parse(payload); }, }; assert.equal(await route.handle({ method: 'GET', url: '/api/gateway-auto' }, response), false); assert.equal(await route.handle({ method: 'POST', url: '/api/gateway-auto' }, response), true); assert.deepEqual(calls, [['operation', 'gateway-auto'], ['enabled', false]]); assert.deepEqual(response.payload, { success: true, gatewayAuto: { mode: 'local-vpn' }, state: { gatewayAuto: { mode: 'local-vpn' }, revision: 7 }, }); body = { enabled: 'false' }; await assert.rejects( route.handle({ method: 'POST', url: '/api/gateway-auto' }, response), (error) => error.code === 'REQUEST_INVALID', ); const gatewayRoute = createGatewayAutoRoute({ appMode: 'gateway', gatewayAuto: { setEnabled: async () => {} }, readBody: async () => ({ enabled: true }), withOperation: async (_kind, operation) => operation(), readStatePayload: async () => ({}), }); await assert.rejects( gatewayRoute.handle({ method: 'POST', url: '/api/gateway-auto' }, response), (error) => error.code === 'REQUEST_INVALID', ); }); test('gateway-auto route and service are the only displaced owners', () => { const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8'); assert.match(source, /createGatewayAutoService\(\{/); assert.match(source, /createGatewayAutoRoute\(\{/); assert.doesNotMatch(source, /let gatewayAutoState/); assert.doesNotMatch(source, /gatewayDiscoveryPromise/); assert.doesNotMatch(source, /applyGatewayAutoState/); assert.doesNotMatch(source, /refreshGatewayAutoMode/); assert.doesNotMatch(source, /req\.url\s*===\s*['"]\/api\/gateway-auto['"]/); });