524 lines
20 KiB
JavaScript
524 lines
20 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
createConnectivityDiagnosticsService,
|
|
CURL_META_MARKER,
|
|
} from '../../dist/server/services/connectivityDiagnosticsService.js';
|
|
import { createConnectivityDiagnosticsUseCase } from '../../dist/server/features/diagnostics/index.js';
|
|
import { createConnectivityDiagnosticsRoute } from '../../dist/server/http/routes/connectivityDiagnosticsRoute.js';
|
|
|
|
const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.ts'), 'utf8');
|
|
|
|
function response(body = '', overrides = {}) {
|
|
return {
|
|
exitCode: 0,
|
|
error: '',
|
|
stderr: '',
|
|
stdout: `${body}${CURL_META_MARKER}${JSON.stringify({
|
|
exitcode: 0,
|
|
http_code: 204,
|
|
time_starttransfer: 0.12,
|
|
time_total: 0.15,
|
|
...overrides,
|
|
})}`,
|
|
};
|
|
}
|
|
|
|
test('connectivity diagnostics force separate direct and VPN paths', async () => {
|
|
const calls = [];
|
|
const execute = async (args) => {
|
|
calls.push(args);
|
|
const vpn = args.includes('--proxy');
|
|
const url = args.at(-1);
|
|
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
|
|
if (url.includes('api6')) return response(vpn ? '2001:db8::20' : '2001:db8::10');
|
|
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
|
|
return response();
|
|
};
|
|
const service = createConnectivityDiagnosticsService({
|
|
proxyPort: 18080,
|
|
execute,
|
|
now: () => '2026-08-07T12:00:00.000Z',
|
|
});
|
|
|
|
const result = await service.run({ vpnAvailable: true });
|
|
|
|
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
|
|
assert.deepEqual(result.vpn.ipv4.addresses, ['203.0.113.20']);
|
|
assert.equal(result.direct.ipv6, '2001:db8::10');
|
|
assert.equal(result.vpn.ipv6, '2001:db8::20');
|
|
assert.equal(result.direct.network.error, 'invalid network response');
|
|
assert.equal(result.assessment.summary, 'available');
|
|
assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*')));
|
|
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
|
|
});
|
|
|
|
test('connectivity diagnostics endpoint is available in Connect and Gateway through one owner', () => {
|
|
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
|
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
|
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
|
|
assert.match(server, /sendState: \(res\) => stateRoute\.send\(res\)/);
|
|
assert.match(server, /createConnectivityDiagnosticsUseCase\(\{/);
|
|
assert.match(server, /createConnectivityDiagnosticsRoute\(\{/);
|
|
assert.match(server, /connectivityDiagnosticsRoute\.handle\(req, res\)/);
|
|
assert.doesNotMatch(server, /['"]\/api\/diagnostics\/connectivity['"]/);
|
|
assert.doesNotMatch(server, /appliedServerId\s*\|\|\s*state\.selectedServerId/);
|
|
});
|
|
|
|
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
|
|
const events = [];
|
|
let state = {
|
|
revision: 3,
|
|
diagnostics: {
|
|
configured: true,
|
|
customServices: [{ id: 'custom-saved', label: 'Saved', url: 'https://example.com/' }],
|
|
hiddenServiceIds: [],
|
|
},
|
|
desiredProfileId: 'primary',
|
|
appliedProfileId: 'primary',
|
|
appliedServerId: 'applied',
|
|
profiles: [{
|
|
id: 'primary',
|
|
desiredServerId: 'selected',
|
|
servers: [
|
|
{ id: 'applied', label: 'Applied before probe' },
|
|
{ id: 'selected', label: 'Selected' },
|
|
],
|
|
}],
|
|
};
|
|
let releaseProbe;
|
|
const probe = new Promise((resolve) => { releaseProbe = resolve; });
|
|
const sourceResult = {
|
|
checkedAt: 'now',
|
|
direct: { available: true },
|
|
vpn: { available: true, server: { id: 'stale', label: 'Stale' }, detail: 7 },
|
|
assessment: { summary: 'available' },
|
|
};
|
|
const useCase = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => {
|
|
events.push('state');
|
|
return state;
|
|
},
|
|
update: () => { throw new Error('unexpected update'); },
|
|
},
|
|
runDiagnostics: async (services, target) => {
|
|
events.push(['probe', services, target]);
|
|
await probe;
|
|
return sourceResult;
|
|
},
|
|
});
|
|
const resultPromise = useCase.run(42);
|
|
state.profiles[0].servers[0].label = 'Changed during probe';
|
|
releaseProbe();
|
|
const result = await resultPromise;
|
|
|
|
assert.deepEqual(events, ['state', ['probe', state.diagnostics.customServices, 42]]);
|
|
assert.deepEqual(result, {
|
|
...sourceResult,
|
|
vpn: {
|
|
...sourceResult.vpn,
|
|
server: { id: 'applied', label: 'Applied before probe' },
|
|
},
|
|
});
|
|
assert.deepEqual(sourceResult.vpn.server, { id: 'stale', label: 'Stale' });
|
|
});
|
|
|
|
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
|
|
const result = { vpn: { available: false }, marker: true };
|
|
const selected = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => ({
|
|
desiredProfileId: 'primary',
|
|
appliedServerId: '',
|
|
profiles: [{
|
|
id: 'primary',
|
|
desiredServerId: 'selected',
|
|
servers: [{ id: 'selected', label: 'Selected' }],
|
|
}],
|
|
}),
|
|
update: () => { throw new Error('unexpected update'); },
|
|
},
|
|
runDiagnostics: async () => result,
|
|
});
|
|
assert.deepEqual((await selected.run(null)).vpn.server, {
|
|
id: 'selected',
|
|
label: 'Selected',
|
|
});
|
|
|
|
const missingApplied = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => ({
|
|
desiredProfileId: 'primary',
|
|
appliedProfileId: 'primary',
|
|
appliedServerId: 'missing',
|
|
profiles: [{
|
|
id: 'primary',
|
|
desiredServerId: 'selected',
|
|
servers: [{ id: 'selected', label: 'Selected' }],
|
|
}],
|
|
}),
|
|
update: () => { throw new Error('unexpected update'); },
|
|
},
|
|
runDiagnostics: async () => result,
|
|
});
|
|
assert.equal((await missingApplied.run(null)).vpn.server, null);
|
|
|
|
const stateError = new Error('state failed');
|
|
let probes = 0;
|
|
const brokenState = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => { throw stateError; },
|
|
update: () => { throw new Error('unexpected update'); },
|
|
},
|
|
runDiagnostics: async () => { probes += 1; return result; },
|
|
});
|
|
await assert.rejects(brokenState.run(null), (error) => error === stateError);
|
|
assert.equal(probes, 0);
|
|
|
|
const probeError = new Error('probe failed');
|
|
const brokenProbe = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => ({ profiles: [] }),
|
|
update: () => { throw new Error('unexpected update'); },
|
|
},
|
|
runDiagnostics: async () => { throw probeError; },
|
|
});
|
|
await assert.rejects(brokenProbe.run(null), (error) => error === probeError);
|
|
});
|
|
|
|
test('diagnostics settings validate and replace one canonical revision', () => {
|
|
let state = {
|
|
revision: 7,
|
|
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
|
};
|
|
const useCase = createConnectivityDiagnosticsUseCase({
|
|
state: {
|
|
read: () => state,
|
|
update: (mutator) => {
|
|
state = { ...mutator(state), revision: state.revision + 1 };
|
|
return state;
|
|
},
|
|
},
|
|
runDiagnostics: async () => ({ vpn: {} }),
|
|
});
|
|
useCase.updateSettings({
|
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
|
hiddenServiceIds: ['speedtest'],
|
|
}, 7);
|
|
assert.deepEqual(state.diagnostics, {
|
|
configured: true,
|
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
|
hiddenServiceIds: ['speedtest'],
|
|
});
|
|
assert.equal(state.revision, 8);
|
|
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
|
code: 'STATE_CONFLICT',
|
|
});
|
|
assert.throws(() => useCase.updateSettings({
|
|
customServices: [{ id: 'bad', label: 'Router', url: 'http://router.local/' }],
|
|
hiddenServiceIds: [],
|
|
}, 8), { code: 'REQUEST_INVALID' });
|
|
});
|
|
|
|
function routeResponse() {
|
|
return {
|
|
writeHead(status, headers) {
|
|
this.status = status;
|
|
this.headers = headers;
|
|
},
|
|
end(payload) {
|
|
this.payload = JSON.parse(payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
test('connectivity route preserves exact URL, defaults and raw response', async () => {
|
|
const calls = [];
|
|
let body = {};
|
|
let bodyReads = 0;
|
|
const route = createConnectivityDiagnosticsRoute({
|
|
diagnostics: {
|
|
run: async (...args) => {
|
|
calls.push(args);
|
|
return { checkedAt: 'now', vpn: { server: null } };
|
|
},
|
|
updateSettings: (...args) => calls.push(['settings', ...args]),
|
|
},
|
|
readBody: async () => {
|
|
bodyReads += 1;
|
|
return body;
|
|
},
|
|
sendState: async (response) => {
|
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
response.end(JSON.stringify({ success: true, state: {} }));
|
|
},
|
|
});
|
|
const res = routeResponse();
|
|
assert.equal(await route.handle({
|
|
method: 'POST',
|
|
url: '/api/diagnostics/connectivity',
|
|
}, res), true);
|
|
assert.deepEqual(calls, [[null]]);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
|
assert.deepEqual(res.payload, { checkedAt: 'now', vpn: { server: null } });
|
|
|
|
body = { services: null, target: 17 };
|
|
await route.handle({ method: 'POST', url: '/api/diagnostics/connectivity' }, routeResponse());
|
|
assert.deepEqual(calls.at(-1), [17]);
|
|
|
|
body = { settings: { customServices: [], hiddenServiceIds: [] }, expectedRevision: 4 };
|
|
const settingsResponse = routeResponse();
|
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/diagnostics/settings' }, settingsResponse), true);
|
|
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
|
assert.equal(settingsResponse.payload.success, true);
|
|
|
|
for (const [method, url] of [
|
|
['GET', '/api/diagnostics/connectivity'],
|
|
['POST', '/api/diagnostics/connectivity?target=all'],
|
|
['POST', '/api/diagnostics/settings'],
|
|
['POST', '/api/diagnostics/other'],
|
|
]) {
|
|
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
|
}
|
|
assert.equal(bodyReads, 3);
|
|
});
|
|
|
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
|
const attempts = { direct: 0, vpn: 0 };
|
|
const execute = async (args) => {
|
|
const route = args.includes('--proxy') ? 'vpn' : 'direct';
|
|
attempts[route] += 1;
|
|
if (route === 'vpn' && attempts.vpn === 1) {
|
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
|
}
|
|
const address = route === 'vpn' ? '203.0.113.20' : '198.51.100.10';
|
|
return response(`{"ipv4":"${address}"}`, {
|
|
time_starttransfer: route === 'vpn' ? attempts.vpn * 0.1 : attempts.direct * 0.1,
|
|
});
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: true, target: 'ip:yandex-internet' });
|
|
|
|
assert.equal(result.direct.ipv4.sources[0].attempts, 3);
|
|
assert.equal(result.vpn.ipv4.sources[0].attempts, 3);
|
|
assert.equal(result.direct.ipv4.sources[0].address, '198.51.100.10');
|
|
assert.equal(result.vpn.ipv4.sources[0].address, '203.0.113.20');
|
|
assert.equal(result.direct.ipv4.sources[0].latencyMs, 200);
|
|
assert.equal(result.vpn.ipv4.sources[0].latencyMs, 250);
|
|
});
|
|
|
|
test('a targeted network row reports provider, ASN and location through both forced paths', async () => {
|
|
const attempts = { direct: 0, vpn: 0 };
|
|
const calls = [];
|
|
const execute = async (args) => {
|
|
calls.push(args);
|
|
const route = args.includes('--proxy') ? 'vpn' : 'direct';
|
|
attempts[route] += 1;
|
|
if (route === 'direct' && attempts.direct === 1) return response('{invalid');
|
|
return response(JSON.stringify(route === 'vpn' ? {
|
|
success: true,
|
|
ip: '2a12:bec4:1bb0:10bb::2',
|
|
city: 'Amsterdam',
|
|
country_code: 'NL',
|
|
connection: { asn: 9009, isp: 'M247 Europe' },
|
|
} : {
|
|
success: true,
|
|
ip: '198.51.100.10',
|
|
city: 'Moscow',
|
|
country_code: 'RU',
|
|
connection: { asn: 12389, isp: 'Rostelecom' },
|
|
}));
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: true, target: 'network' });
|
|
|
|
assert.deepEqual(attempts, { direct: 3, vpn: 3 });
|
|
assert.deepEqual(result.direct.network, {
|
|
address: '198.51.100.10',
|
|
asn: 'AS12389',
|
|
provider: 'Rostelecom',
|
|
city: 'Moscow',
|
|
country: 'RU',
|
|
attempts: 3,
|
|
error: null,
|
|
});
|
|
assert.equal(result.vpn.network.address, '2a12:bec4:1bb0:10bb::2');
|
|
assert.equal(result.vpn.network.asn, 'AS9009');
|
|
assert.equal(result.vpn.network.provider, 'M247 Europe');
|
|
assert.ok(calls.every((args) => args.at(-1) === 'https://ipwho.is/' && args.includes('--ipv4')));
|
|
assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*')));
|
|
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
|
|
});
|
|
|
|
test('a targeted service row averages three measurements and ignores one transient failure', async () => {
|
|
const attempts = { direct: 0, vpn: 0 };
|
|
const execute = async (args) => {
|
|
const route = args.includes('--proxy') ? 'vpn' : 'direct';
|
|
attempts[route] += 1;
|
|
if (route === 'direct' && attempts.direct === 1) {
|
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
|
}
|
|
return response('', {
|
|
time_starttransfer: route === 'direct' ? attempts.direct * 0.1 : attempts.vpn * 0.2,
|
|
});
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: true, target: 'site:yandex' });
|
|
|
|
assert.deepEqual(attempts, { direct: 3, vpn: 3 });
|
|
assert.equal(result.direct.sites[0].status, 'available');
|
|
assert.equal(result.direct.sites[0].attempts, 3);
|
|
assert.equal(result.direct.sites[0].latencyMs, 250);
|
|
assert.equal(result.vpn.sites[0].latencyMs, 400);
|
|
});
|
|
|
|
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
|
|
const attempts = new Map();
|
|
const execute = async (args) => {
|
|
const vpn = args.includes('--proxy');
|
|
const url = args.at(-1);
|
|
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
|
|
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
|
|
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
|
|
const key = `${vpn}:${url}`;
|
|
attempts.set(key, (attempts.get(key) || 0) + 1);
|
|
if (!vpn && url.includes('youtube')) {
|
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
|
}
|
|
return response();
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: true });
|
|
|
|
assert.equal(result.assessment.summary, 'likely-direct-restriction');
|
|
assert.equal(
|
|
result.assessment.comparisons.find((item) => item.id === 'youtube').assessment,
|
|
'likely-direct-restriction',
|
|
);
|
|
assert.equal(attempts.get('false:https://www.youtube.com/generate_204'), 2);
|
|
});
|
|
|
|
test('connectivity diagnostics skips VPN probes when sing-box is off', async () => {
|
|
const calls = [];
|
|
const execute = async (args) => {
|
|
calls.push(args);
|
|
const url = args.at(-1);
|
|
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
|
|
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
|
|
if (url.includes('ipify')) return response('198.51.100.10');
|
|
return response();
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: false });
|
|
|
|
assert.equal(result.vpn.available, false);
|
|
assert.equal(result.assessment.summary, 'vpn-off');
|
|
assert.equal(calls.some((args) => args.includes('--proxy')), false);
|
|
});
|
|
|
|
test('connectivity diagnostics keeps every IP source in a partial snapshot', async () => {
|
|
const execute = async (args) => {
|
|
const url = args.at(-1);
|
|
if (url.includes('cloudflare')) {
|
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
|
}
|
|
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
|
|
if (url.includes('ipify')) return response('198.51.100.10');
|
|
if (url.includes('amazonaws')) return response('198.51.100.10');
|
|
return response();
|
|
};
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
|
.run({ vpnAvailable: false });
|
|
|
|
assert.equal(result.direct.internetAvailable, true);
|
|
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
|
|
assert.deepEqual(result.direct.ipv4.sources.map((source) => source.source), [
|
|
'cloudflare',
|
|
'ipify',
|
|
'aws',
|
|
'icanhazip',
|
|
'ifconfig-me',
|
|
'yandex-internet',
|
|
]);
|
|
});
|
|
|
|
test('connectivity diagnostics pins public custom services and rejects private destinations', async () => {
|
|
const calls = [];
|
|
const execute = async (args) => {
|
|
calls.push(args);
|
|
const url = args.at(-1);
|
|
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
|
|
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
|
|
if (url.includes('ipify')) return response('198.51.100.10');
|
|
return response();
|
|
};
|
|
const lookup = async (hostname) => hostname === 'router.local'
|
|
? [{ address: '192.168.50.1', family: 4 }]
|
|
: [{ address: '93.184.216.34', family: 4 }];
|
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute, lookup })
|
|
.run({
|
|
vpnAvailable: false,
|
|
services: [
|
|
{ id: 'custom-public', label: 'Example', url: 'https://example.com/status' },
|
|
{ id: 'custom-private', label: 'Router', url: 'https://router.local/' },
|
|
],
|
|
});
|
|
|
|
const publicCall = calls.find((args) => args.at(-1) === 'https://example.com/status');
|
|
assert.ok(publicCall);
|
|
assert.equal(publicCall.includes('--location'), false);
|
|
assert.deepEqual(publicCall.slice(publicCall.indexOf('--resolve'), publicCall.indexOf('--resolve') + 2), [
|
|
'--resolve',
|
|
'example.com:443:93.184.216.34',
|
|
]);
|
|
assert.equal(calls.some((args) => args.at(-1) === 'https://router.local/'), false);
|
|
assert.equal(result.direct.sites.find((site) => site.id === 'custom-private').stage, 'validation');
|
|
assert.equal(result.assessment.comparisons.some((item) => item.id === 'custom-public'), true);
|
|
});
|
|
|
|
test('a targeted custom row validates and samples only that service', async () => {
|
|
const lookups = [];
|
|
const calls = [];
|
|
const result = await createConnectivityDiagnosticsService({
|
|
proxyPort: 18080,
|
|
execute: async (args) => {
|
|
calls.push(args.at(-1));
|
|
return response();
|
|
},
|
|
lookup: async (hostname) => {
|
|
lookups.push(hostname);
|
|
return [{ address: '93.184.216.34', family: 4 }];
|
|
},
|
|
}).run({
|
|
vpnAvailable: false,
|
|
services: [
|
|
{ id: 'custom-first', url: 'https://first.example/' },
|
|
{ id: 'custom-second', url: 'https://second.example/' },
|
|
],
|
|
target: 'site:custom-second',
|
|
});
|
|
|
|
assert.deepEqual(lookups, ['second.example']);
|
|
assert.deepEqual(calls, ['https://second.example/', 'https://second.example/', 'https://second.example/']);
|
|
assert.equal(result.direct.sites[0].latencyMs, 120);
|
|
});
|
|
|
|
test('failover VPN checks apply their per-service timeout to curl', async () => {
|
|
const calls = [];
|
|
await createConnectivityDiagnosticsService({
|
|
proxyPort: 18080,
|
|
execute: async (args) => { calls.push(args); return response(); },
|
|
}).runVpn({ target: 'site:youtube', timeoutMs: 9_000 });
|
|
|
|
assert.equal(calls.length, 3);
|
|
for (const args of calls) {
|
|
assert.equal(args[args.indexOf('--max-time') + 1], '9');
|
|
assert.equal(args[args.indexOf('--connect-timeout') + 1], '3');
|
|
assert.ok(args.includes('http://127.0.0.1:18080'));
|
|
}
|
|
});
|