Persist configurable connectivity diagnostics
This commit is contained in:
@@ -59,6 +59,7 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
|
||||
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\)/);
|
||||
@@ -69,6 +70,12 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
|
||||
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',
|
||||
@@ -90,9 +97,12 @@ test('connectivity use case captures applied server before probes and preserves
|
||||
assessment: { summary: 'available' },
|
||||
};
|
||||
const useCase = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => {
|
||||
events.push('state');
|
||||
return state;
|
||||
state: {
|
||||
read: () => {
|
||||
events.push('state');
|
||||
return state;
|
||||
},
|
||||
update: () => { throw new Error('unexpected update'); },
|
||||
},
|
||||
runDiagnostics: async (services, target) => {
|
||||
events.push(['probe', services, target]);
|
||||
@@ -100,12 +110,12 @@ test('connectivity use case captures applied server before probes and preserves
|
||||
return sourceResult;
|
||||
},
|
||||
});
|
||||
const resultPromise = useCase.run({ raw: true }, 42);
|
||||
const resultPromise = useCase.run(42);
|
||||
state.profiles[0].servers[0].label = 'Changed during probe';
|
||||
releaseProbe();
|
||||
const result = await resultPromise;
|
||||
|
||||
assert.deepEqual(events, ['state', ['probe', { raw: true }, 42]]);
|
||||
assert.deepEqual(events, ['state', ['probe', state.diagnostics.customServices, 42]]);
|
||||
assert.deepEqual(result, {
|
||||
...sourceResult,
|
||||
vpn: {
|
||||
@@ -119,52 +129,98 @@ test('connectivity use case captures applied server before probes and preserves
|
||||
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
|
||||
const result = { vpn: { available: false }, marker: true };
|
||||
const selected = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
desiredProfileId: 'primary',
|
||||
appliedServerId: '',
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}],
|
||||
}),
|
||||
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, null)).vpn.server, {
|
||||
assert.deepEqual((await selected.run(null)).vpn.server, {
|
||||
id: 'selected',
|
||||
label: 'Selected',
|
||||
});
|
||||
|
||||
const missingApplied = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
desiredProfileId: 'primary',
|
||||
appliedProfileId: 'primary',
|
||||
appliedServerId: 'missing',
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}],
|
||||
}),
|
||||
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);
|
||||
assert.equal((await missingApplied.run(null)).vpn.server, null);
|
||||
|
||||
const stateError = new Error('state failed');
|
||||
let probes = 0;
|
||||
const brokenState = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => { throw stateError; },
|
||||
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);
|
||||
await assert.rejects(brokenState.run(null), (error) => error === stateError);
|
||||
assert.equal(probes, 0);
|
||||
|
||||
const probeError = new Error('probe failed');
|
||||
const brokenProbe = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({ servers: [] }),
|
||||
state: {
|
||||
read: () => ({ profiles: [] }),
|
||||
update: () => { throw new Error('unexpected update'); },
|
||||
},
|
||||
runDiagnostics: async () => { throw probeError; },
|
||||
});
|
||||
await assert.rejects(brokenProbe.run([], null), (error) => error === 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() {
|
||||
@@ -189,34 +245,46 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
||||
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.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), [null, 17]);
|
||||
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, 2);
|
||||
assert.equal(bodyReads, 3);
|
||||
});
|
||||
|
||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
||||
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
|
||||
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||
import { STATE_SCHEMA_VERSION } from '../../dist/server/services/stateStore.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
@@ -305,7 +306,7 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||||
assert.equal(migratedState.schemaVersion, 6);
|
||||
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.equal(migratedState.profiles.length, 1);
|
||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||
@@ -318,6 +319,7 @@ setInterval(() => {}, 60_000);
|
||||
'apiVersion',
|
||||
'configExists',
|
||||
'connection',
|
||||
'diagnostics',
|
||||
'fetchedAt',
|
||||
'gatewayAuto',
|
||||
'generatedAt',
|
||||
|
||||
@@ -74,7 +74,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t) => {
|
||||
test('schema v5 migrates rules and diagnostics settings with an exact backup', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
schemaVersion: 5,
|
||||
@@ -97,7 +97,12 @@ test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, 6);
|
||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.deepEqual(migrated.diagnostics, {
|
||||
configured: false,
|
||||
customServices: [],
|
||||
hiddenServiceIds: [],
|
||||
});
|
||||
assert.equal(migrated.routeRulesRevision, 7);
|
||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
|
||||
@@ -105,7 +110,7 @@ test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t
|
||||
['domain', 'direct'],
|
||||
]);
|
||||
assert.equal(store.migration.fromVersion, 5);
|
||||
assert.equal(store.migration.toVersion, 6);
|
||||
assert.equal(store.migration.toVersion, STATE_SCHEMA_VERSION);
|
||||
assert.match(store.migration.backupPath, /\.backup-v5-/);
|
||||
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user