2026-08-09 11:16:30 +08:00
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import os from 'os';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
|
|
|
|
|
import { createSettingsAccessors } from './cli-settings-accessors.js';
|
|
|
|
|
import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js';
|
|
|
|
|
|
|
|
|
|
const withTempDir = async (fn) => {
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-settings-accessors-'));
|
|
|
|
|
try {
|
|
|
|
|
return await fn(dir);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-09 11:39:42 +08:00
|
|
|
const makeAccessors = (dir, overrides = {}) =>
|
|
|
|
|
createSettingsAccessors({
|
|
|
|
|
fsPromises: fs.promises,
|
|
|
|
|
path,
|
|
|
|
|
dataDir: dir,
|
|
|
|
|
settingsFileName: 'settings.json',
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Wraps writeFile so each write lands in two chunks with a pause in between —
|
|
|
|
|
// a stand-in for a large, slow write on a real disk (one open handle, so the
|
|
|
|
|
// file grows from the prefix to the full payload). With a non-atomic writer a
|
|
|
|
|
// concurrent reader deterministically catches the half-written file in that
|
|
|
|
|
// window; with the atomic tmp+rename writer the target only ever changes via a
|
|
|
|
|
// complete rename, so the window is never observable.
|
|
|
|
|
const makeSlowWriteFs = () => {
|
|
|
|
|
const realFs = fs.promises;
|
|
|
|
|
const slowWriteFile = async (filePath, data) => {
|
|
|
|
|
const handle = await realFs.open(filePath, 'w');
|
|
|
|
|
try {
|
|
|
|
|
const half = Math.floor(data.length / 2);
|
|
|
|
|
await handle.writeFile(data.slice(0, half), 'utf8');
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
|
|
|
await handle.writeFile(data.slice(half), 'utf8');
|
|
|
|
|
} finally {
|
|
|
|
|
await handle.close();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return { slowWriteFile, fsPromises: { ...realFs, writeFile: slowWriteFile } };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Runs `writer` against filePath while a concurrent reader hammers it; returns
|
|
|
|
|
// how many times the reader observed an unparseable (torn) payload. ENOENT
|
|
|
|
|
// during the very first write is not a tear and is excluded.
|
|
|
|
|
const countTornReads = async (filePath, writer, iterations) => {
|
|
|
|
|
const big = { theme: 'dark', filler: 'x'.repeat(4096) };
|
|
|
|
|
let torn = 0;
|
|
|
|
|
let stop = false;
|
|
|
|
|
const reader = (async () => {
|
|
|
|
|
while (!stop) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
|
|
|
|
|
if (parsed && typeof parsed === 'object') {
|
|
|
|
|
expect(parsed.theme).toBe('dark');
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error?.code !== 'ENOENT') {
|
|
|
|
|
torn += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
for (let i = 0; i < iterations; i += 1) {
|
|
|
|
|
await writer({ ...big, n: i });
|
|
|
|
|
}
|
|
|
|
|
stop = true;
|
|
|
|
|
await reader;
|
|
|
|
|
return torn;
|
|
|
|
|
};
|
2026-08-09 11:16:30 +08:00
|
|
|
|
|
|
|
|
describe('cli settings accessors', () => {
|
|
|
|
|
it('persists the full object atomically and cleans up its tmp file', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
await accessors.writeSettingsToDisk({ theme: 'dark', count: 3 });
|
|
|
|
|
|
|
|
|
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
|
|
|
|
|
expect(raw).toEqual({ theme: 'dark', count: 3 });
|
|
|
|
|
|
|
|
|
|
const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-'));
|
|
|
|
|
expect(leftovers).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 11:39:42 +08:00
|
|
|
it('atomic writes: concurrent readers never observe a torn file, even under slow writes', async () => {
|
2026-08-09 11:16:30 +08:00
|
|
|
await withTempDir(async (dir) => {
|
2026-08-09 11:39:42 +08:00
|
|
|
const { fsPromises } = makeSlowWriteFs();
|
|
|
|
|
const accessors = makeAccessors(dir, { fsPromises });
|
2026-08-09 11:16:30 +08:00
|
|
|
const filePath = path.join(dir, 'settings.json');
|
|
|
|
|
|
2026-08-09 11:39:42 +08:00
|
|
|
// Each write is chunked with a pause, yet the reader must never see a
|
|
|
|
|
// partial payload: the target only changes via a complete atomic rename.
|
|
|
|
|
const torn = await countTornReads(filePath, (settings) => accessors.writeSettingsToDisk(settings), 20);
|
|
|
|
|
expect(torn).toBe(0);
|
2026-08-09 11:16:30 +08:00
|
|
|
|
2026-08-09 11:39:42 +08:00
|
|
|
const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-'));
|
|
|
|
|
expect(leftovers).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-09 11:16:30 +08:00
|
|
|
|
2026-08-09 11:39:42 +08:00
|
|
|
it('demonstrates the protected failure mode: a naive direct writer tears under the same slow write', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const { slowWriteFile } = makeSlowWriteFs();
|
|
|
|
|
const filePath = path.join(dir, 'settings.json');
|
|
|
|
|
|
|
|
|
|
// The old CLI accessor wrote straight to settings.json with writeFile.
|
|
|
|
|
// The same slow-write load therefore MUST produce torn reads — proving
|
|
|
|
|
// the concurrency test above can actually fail on the pre-fix writer.
|
|
|
|
|
const torn = await countTornReads(
|
|
|
|
|
filePath,
|
|
|
|
|
(settings) => slowWriteFile(filePath, JSON.stringify(settings)),
|
|
|
|
|
20,
|
|
|
|
|
);
|
|
|
|
|
expect(torn).toBeGreaterThan(0);
|
2026-08-09 11:16:30 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lenient read maps a corrupt file to {} for config lookup', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc');
|
|
|
|
|
expect(await accessors.readSettingsFromDiskMigrated()).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('strict read throws on a corrupt file instead of reporting "no settings"', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc');
|
|
|
|
|
await expect(accessors.readSettingsStrict()).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('strict read throws on a non-object payload', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'settings.json'), '"just a string"');
|
2026-08-28 02:08:09 +03:00
|
|
|
await expect(accessors.readSettingsStrict()).rejects.toThrow(/corrupt or unreadable/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('names the settings file in the strict read failure', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
const filePath = path.join(dir, 'settings.json');
|
|
|
|
|
fs.writeFileSync(filePath, '{"unfinished": "trunc');
|
|
|
|
|
await expect(accessors.readSettingsStrict()).rejects.toThrow(filePath);
|
2026-08-09 11:16:30 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('strict read treats only a genuinely missing file as no settings', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
expect(await accessors.readSettingsStrict()).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not regenerate the relay identity off a corrupt settings file', async () => {
|
|
|
|
|
await withTempDir(async (dir) => {
|
|
|
|
|
const accessors = makeAccessors(dir);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(dir, 'settings.json'),
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
relaySigningKey: {
|
|
|
|
|
privateJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ format: 'jwk' }),
|
|
|
|
|
publicJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).publicKey.export({ format: 'jwk' }),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const identity = await createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity();
|
|
|
|
|
const serverIdBefore = identity.serverId;
|
|
|
|
|
|
|
|
|
|
// Corrupt the file, then ask for the identity again: the strict gate must
|
|
|
|
|
// make this FAIL rather than mint a replacement keypair.
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'settings.json'), '{"relaySigningKey": {"unfinished');
|
|
|
|
|
await expect(createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity()).rejects.toThrow();
|
|
|
|
|
expect(serverIdBefore).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|