diff --git a/docs/product/data-consistency-regressions.md b/docs/product/data-consistency-regressions.md new file mode 100644 index 0000000..ead86de --- /dev/null +++ b/docs/product/data-consistency-regressions.md @@ -0,0 +1,17 @@ +# Data consistency regression suite + +`npm test` is the required fast regression gate. It uses generated fixtures, temporary directories, loopback HTTP servers and a fake sing-box executable; it does not require internet, root or an installed sing-box. + +The protected invariants are: + +| Invariant | Regression coverage | +|---|---| +| One backend canonical snapshot owns servers and desired/applied selection | `test/data-consistency-regression.test.js`, `test/server/state-contract.test.js` | +| Revisions increase and an older response cannot replace newer state | `test/server/state-contract.test.js`, `test/web/harbor-state.test.js` | +| Provider failure, parser failure and runtime failure do not partially commit subscription state | `test/server/state-contract.test.js` | +| Atomic write failure preserves the last file and corrupt JSON preserves its original bytes | `test/server/state-store.test.js` | +| Legacy state migrates with an explicit result for ambiguous selection | `test/server/state-store.test.js` | +| Stable IDs survive reorder and duplicate labels for 1, 30 and 300 servers | `test/data-consistency-regression.test.js`, `test/server/subscription.test.js` | +| Initial control outage does not invent domain state; repeated failures retain and mark the last snapshot stale | `test/web/harbor-state.test.js` | + +Fixtures are generated in test code to keep the suite small and deterministic. Packet-level networking, browser screenshots and accessibility automation are intentionally deferred to their dedicated roadmap tasks. diff --git a/test/data-consistency-regression.test.js b/test/data-consistency-regression.test.js new file mode 100644 index 0000000..9b30d0c --- /dev/null +++ b/test/data-consistency-regression.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseSubscriptionBody } from '../src/server/subscription.js'; +import { createStateSnapshot, normalizeStoredState } from '../src/shared/contracts/state.js'; + +const outbound = (index) => ({ + type: 'vless', + tag: index % 2 ? 'Amsterdam' : 'Frankfurt', + server: `vpn-${index}.example.test`, + server_port: 443, +}); + +const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds })); + +test('data invariant: 1, 30 and 300 servers keep unique IDs across reorder and duplicate labels', () => { + for (const size of [1, 30, 300]) { + const source = Array.from({ length: size }, (_, index) => outbound(index)); + const before = parse(source).servers; + const after = parse([...source].reverse()).servers; + + assert.equal(before.length, size); + assert.equal(new Set(before.map((server) => server.id)).size, size); + assert.deepEqual( + after.map((server) => server.id).sort(), + before.map((server) => server.id).sort(), + ); + } +}); + +test('data invariant: one canonical snapshot owns server selection and never exposes the subscription URL', () => { + const servers = parse(Array.from({ length: 30 }, (_, index) => outbound(index))).servers; + const selectedServerId = servers[17].id; + const stored = normalizeStoredState({ + revision: 9, + subscriptionUrl: 'https://provider.example/private-token', + servers, + selectedServerId, + appliedServerId: selectedServerId, + }); + const snapshot = createStateSnapshot({ + storedState: stored, + runtime: { running: false }, + appMode: 'client', + configExists: true, + subscriptionHost: 'provider.example/…', + now: new Date('2026-07-12T12:00:00.000Z'), + }); + + assert.equal(snapshot.revision, 9); + assert.deepEqual(snapshot.selection, { desiredServerId: selectedServerId, appliedServerId: selectedServerId }); + assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test'); + assert.equal(JSON.stringify(snapshot).includes('private-token'), false); +}); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 30e3588..64626f5 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -90,7 +90,7 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot', ); }); -test('GET and domain mutations return one state shape with monotonic revisions', async (t) => { +test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-')); const binDir = path.join(dir, 'bin'); const config = { diff --git a/test/server/state-store.test.js b/test/server/state-store.test.js index bcf4410..ef58d4f 100644 --- a/test/server/state-store.test.js +++ b/test/server/state-store.test.js @@ -17,7 +17,7 @@ const fixture = (t) => { return path.join(directory, 'state.json'); }; -test('a failure before rename preserves the last successful file', (t) => { +test('data invariant: failure before rename preserves the last successful file', (t) => { const filePath = fixture(t); atomicWriteJson(filePath, { revision: 1 }); @@ -79,7 +79,7 @@ test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => { assert.equal(migrated.servers.length, 2); }); -test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => { +test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => { const filePath = fixture(t); fs.writeFileSync(filePath, '{broken'); diff --git a/test/web/harbor-state.test.js b/test/web/harbor-state.test.js index e193775..6f5477e 100644 --- a/test/web/harbor-state.test.js +++ b/test/web/harbor-state.test.js @@ -26,7 +26,7 @@ function deferred() { return { promise, resolve }; } -test('an older polling promise cannot replace a newer mutation snapshot', async () => { +test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => { let state = receive(initialHarborState, snapshot(1, 'one')); const poll = deferred(); const mutation = deferred(); @@ -68,7 +68,7 @@ test('pending selection is cleared when its server disappears', () => { assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, ''); }); -test('initial 500 and connection refusal become retryable boot failures', () => { +test('data invariant: initial control outage is retryable without a fabricated snapshot', () => { const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 }); const refused = new TypeError('fetch failed');