109 lines
3.8 KiB
JavaScript
109 lines
3.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
atomicWriteJson,
|
|
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-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
return path.join(directory, 'state.json');
|
|
};
|
|
|
|
test('a failure before rename preserves the last successful file', (t) => {
|
|
const filePath = fixture(t);
|
|
atomicWriteJson(filePath, { revision: 1 });
|
|
|
|
assert.throws(
|
|
() => atomicWriteJson(filePath, { revision: 2 }, {
|
|
beforeRename: () => { throw new Error('injected failure'); },
|
|
}),
|
|
/injected failure/,
|
|
);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')), { revision: 1 });
|
|
assert.equal(
|
|
fs.readdirSync(path.dirname(filePath)).some((name) => name.endsWith('.tmp')),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => {
|
|
const filePath = fixture(t);
|
|
const legacy = {
|
|
schemaVersion: 2,
|
|
revision: 7,
|
|
selectedTag: 'nl',
|
|
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
|
|
};
|
|
fs.writeFileSync(filePath, JSON.stringify(legacy));
|
|
|
|
const store = createStateStore(filePath, {
|
|
now: () => new Date('2026-07-11T12:00:00.000Z'),
|
|
});
|
|
const migrated = store.read();
|
|
|
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
|
assert.deepEqual(migrated.routeRules, [
|
|
{ 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');
|
|
|
|
const store = createStateStore(filePath, {
|
|
now: () => new Date('2026-07-11T12:00:00.000Z'),
|
|
});
|
|
const recovered = store.read();
|
|
|
|
assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION);
|
|
assert.equal(recovered.revision, 0);
|
|
assert.equal(store.recovery.kind, 'corrupt-json');
|
|
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
|
|
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
|
});
|
|
|
|
test('concurrent updates are serialized without lost values', async (t) => {
|
|
const store = createStateStore(fixture(t));
|
|
store.read();
|
|
|
|
await Promise.all(Array.from({ length: 50 }, () => Promise.resolve().then(() => (
|
|
store.update((state) => ({ ...state, counter: (state.counter || 0) + 1 }))
|
|
))));
|
|
|
|
assert.equal(store.read().counter, 50);
|
|
assert.throws(() => store.update(async (state) => state), /must be synchronous/);
|
|
});
|