Introduce stable server IDs for subscription state
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
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, '../..');
|
||||
@@ -59,10 +60,12 @@ async function waitForState(port, child, 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: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
|
||||
servers: [legacyServer],
|
||||
});
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState: stored,
|
||||
@@ -76,8 +79,8 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
|
||||
|
||||
assert.equal(snapshot.apiVersion, 1);
|
||||
assert.deepEqual(snapshot.selection, {
|
||||
desiredServerId: 'legacy',
|
||||
appliedServerId: 'legacy',
|
||||
desiredServerId: legacyServerId,
|
||||
appliedServerId: legacyServerId,
|
||||
});
|
||||
assert.equal(snapshot.connection.process, 'running');
|
||||
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
|
||||
@@ -100,6 +103,7 @@ test('GET and domain mutations return one state shape with monotonic revisions',
|
||||
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
|
||||
@@ -190,7 +194,7 @@ setInterval(() => {}, 60_000);
|
||||
runtime: { singBox: '1.12.13' },
|
||||
});
|
||||
assertStateSnapshot(initial);
|
||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||
assert.equal(initial.selection.appliedServerId, testServerId);
|
||||
assert.deepEqual(initial.route.localRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
]);
|
||||
@@ -266,7 +270,7 @@ setInterval(() => {}, 60_000);
|
||||
);
|
||||
assert.equal(missingServer.response.status, 404);
|
||||
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
|
||||
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn');
|
||||
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);
|
||||
@@ -285,10 +289,14 @@ setInterval(() => {}, 60_000);
|
||||
const fetchesBeforeImport = providerFetchCount;
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
|
||||
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
|
||||
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
|
||||
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: 'test-vpn',
|
||||
appliedServerId: 'test-vpn',
|
||||
desiredServerId: testServerId,
|
||||
appliedServerId: testServerId,
|
||||
});
|
||||
assert.equal(applied.state.connection.process, 'running');
|
||||
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
|
||||
@@ -323,7 +331,7 @@ setInterval(() => {}, 60_000);
|
||||
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: 'test-vpn' },
|
||||
{ inbound: ['mixed-in'], outbound: testServerId },
|
||||
]);
|
||||
|
||||
await mutation('/api/singbox/stop');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createStateStore,
|
||||
STATE_SCHEMA_VERSION,
|
||||
} from '../../src/server/services/stateStore.js';
|
||||
import { createServerId } from '../../src/shared/serverIdentity.js';
|
||||
|
||||
const fixture = (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
|
||||
@@ -39,7 +40,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
schemaVersion: 2,
|
||||
revision: 7,
|
||||
selectedTag: 'nl',
|
||||
servers: [{ tag: 'nl' }],
|
||||
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(legacy));
|
||||
|
||||
@@ -53,11 +54,31 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
]);
|
||||
assert.equal(migrated.appliedTag, 'nl');
|
||||
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
|
||||
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
|
||||
assert.equal(store.migration.fromVersion, 2);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
selectedTag: 'Amsterdam',
|
||||
servers: [
|
||||
{ tag: 'Amsterdam', type: 'vless', server: 'nl-1.example', server_port: 443 },
|
||||
{ tag: 'Amsterdam', type: 'vless', server: 'nl-2.example', server_port: 443 },
|
||||
],
|
||||
}));
|
||||
|
||||
const migrated = createStateStore(filePath).read();
|
||||
|
||||
assert.equal(migrated.selectedServerId, '');
|
||||
assert.equal(migrated.appliedServerId, '');
|
||||
assert.equal(migrated.servers.length, 2);
|
||||
});
|
||||
|
||||
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, '{broken');
|
||||
|
||||
@@ -6,26 +6,51 @@ import {
|
||||
selectRefreshedServer,
|
||||
} from '../../src/server/subscription.js';
|
||||
|
||||
test('subscription server tags are trimmed for selection', () => {
|
||||
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||
}));
|
||||
|
||||
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
|
||||
const outbound = (tag, server, server_port = 443) => ({
|
||||
type: 'vless',
|
||||
tag,
|
||||
server,
|
||||
server_port,
|
||||
});
|
||||
|
||||
test('refreshed subscription keeps the selected server when its tag still exists', () => {
|
||||
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||
test('duplicate labels remain independently addressable by stable ID', () => {
|
||||
const { config, servers } = parse([
|
||||
outbound('Amsterdam', 'nl-1.example'),
|
||||
outbound('Amsterdam', 'nl-2.example'),
|
||||
]);
|
||||
|
||||
assert.equal(selectRefreshedServer('nl', servers), 'nl');
|
||||
assert.equal(servers.length, 2);
|
||||
assert.equal(servers[0].label, 'Amsterdam');
|
||||
assert.equal(servers[1].label, 'Amsterdam');
|
||||
assert.notEqual(servers[0].id, servers[1].id);
|
||||
assert.deepEqual(config.outbounds.map((item) => item.tag), servers.map((server) => server.id));
|
||||
});
|
||||
|
||||
test('refreshed subscription selects the first server when the old tag disappeared', () => {
|
||||
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||
test('provider reorder does not change server IDs or selection', () => {
|
||||
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
|
||||
const after = parse([outbound('NL', 'nl.example'), outbound('DE', 'de.example')]).servers;
|
||||
const selectedId = before[1].id;
|
||||
|
||||
assert.equal(selectRefreshedServer('old-name', servers), 'de');
|
||||
assert.deepEqual(
|
||||
new Map(after.map((server) => [server.host, server.id])),
|
||||
new Map(before.map((server) => [server.host, server.id])),
|
||||
);
|
||||
assert.equal(selectRefreshedServer(selectedId, before, after), selectedId);
|
||||
});
|
||||
|
||||
test('refreshed subscription does not select a server before the first user choice', () => {
|
||||
assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), '');
|
||||
test('cosmetic rename keeps selection for the same endpoint', () => {
|
||||
const before = parse([outbound('Old name', 'nl.example')]).servers;
|
||||
const after = parse([outbound('New name', 'nl.example')]).servers;
|
||||
|
||||
assert.equal(after[0].id, before[0].id);
|
||||
assert.equal(selectRefreshedServer(before[0].id, before, after), before[0].id);
|
||||
});
|
||||
|
||||
test('removed selected server requires an explicit new choice', () => {
|
||||
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
|
||||
const after = parse([outbound('DE', 'de.example')]).servers;
|
||||
|
||||
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
|
||||
assert.equal(selectRefreshedServer('', before, after), '');
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ import { instructionBlocks } from '../../src/web/instructions.js';
|
||||
|
||||
test('connection button chooses the only valid client action', () => {
|
||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||
assert.deepEqual(connectionAction({ selectedServerId: 'srv_nl' }), {
|
||||
type: 'apply',
|
||||
selectedTag: 'nl-amsterdam',
|
||||
serverId: 'srv_nl',
|
||||
});
|
||||
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||
assert.equal(connectionAction({}), null);
|
||||
|
||||
Reference in New Issue
Block a user