8 reliability fixes across 8 files (AUDIT-2026-10-27.md findings
9/11/12/13/14/16/20 + calendar credential item):
- dns.js check-update/update HANGS: bare returns + empty else/catch
branches left requests hanging until client timeout; every path now
answers (502 with message); upstream JSON.parse wrapped (non-JSON ->
502)
- calendar.js ICS fetch timeout actually applies: fetchT ignores
opts.timeout (undici) — was running at 5s default, not the intended
15s; now timeoutMs third arg
- calendar.js PUT /sources: updating pass no longer nulls the stored
token (and vice versa) — existing credential record loaded, only the
provided field overwritten
- state-manager.js ATOMIC WRITES: _atomicWrite = tmp file + fs.rename
(POSIX-atomic); write()/update() no longer truncate-then-write the
live file (crash mid-write corrupted services.json/config.json with
no recovery path). Test mock extended to the tmp+rename contract
- dashboard-ws.js auth fallback FAIL-CLOSED: missing authVerifier used
to accept any non-empty session cookie (DC-061 footgun on unwired
boot paths); now rejects + loud CRITICAL log
- middleware.js tailscale gate: new tailscale.failClosed config (default
off = unchanged); when set, outages/errors deny (503) instead of
silently disabling the gate; fail-open path now logs the exact key
- sw.js: offline fallback actually works ((await match) || (await
match) — old Promise||Promise was always truthy); only 2xx cached
(5xx pages were stored and served as the app shell); install is
per-URL tolerant (one bad asset kept the stale SW alive forever)
- config/settings.js GET /config (public): corrupt config.json degrades
to {setupComplete:false, corrupt:true} instead of 500 on every load
SCOPE CORRECTION: audit finding #10 (7 reversed-arg errorResponse
sites) was a FALSE POSITIVE — those files alias message-first error();
see lesson #14. Full suite 173/173 suites, 3740/3740 tests.
222 lines
7.4 KiB
JavaScript
222 lines
7.4 KiB
JavaScript
jest.mock('proper-lockfile');
|
|
jest.mock('fs', () => ({
|
|
existsSync: jest.fn().mockReturnValue(true),
|
|
mkdirSync: jest.fn(),
|
|
writeFileSync: jest.fn(),
|
|
promises: {
|
|
readFile: jest.fn().mockResolvedValue('[]'),
|
|
writeFile: jest.fn().mockResolvedValue(),
|
|
// DC-170 atomic writes: _atomicWrite serializes to a tmp file then renames
|
|
rename: jest.fn().mockResolvedValue(),
|
|
unlink: jest.fn().mockResolvedValue(),
|
|
},
|
|
}));
|
|
|
|
const lockfile = require('proper-lockfile');
|
|
const fs = require('fs');
|
|
const StateManager = require('../src/managers/state-manager');
|
|
|
|
describe('StateManager', () => {
|
|
let sm;
|
|
const TEST_PATH = '/tmp/test-state.json';
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.promises.readFile.mockResolvedValue('[]');
|
|
fs.promises.writeFile.mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
|
lockfile.check.mockResolvedValue(false);
|
|
lockfile.unlock.mockResolvedValue();
|
|
|
|
sm = new StateManager(TEST_PATH);
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('creates file with [] if it does not exist', () => {
|
|
fs.existsSync.mockReturnValue(false);
|
|
new StateManager('/tmp/new-state.json');
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8');
|
|
});
|
|
|
|
it('creates directory recursively if needed', () => {
|
|
fs.existsSync.mockReturnValue(false);
|
|
new StateManager('/tmp/deep/nested/state.json');
|
|
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
});
|
|
|
|
it('does not create file if it exists', () => {
|
|
fs.existsSync.mockReturnValue(true);
|
|
fs.writeFileSync.mockClear();
|
|
new StateManager(TEST_PATH);
|
|
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('read', () => {
|
|
it('returns parsed JSON from file', async () => {
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }]));
|
|
const data = await sm.read();
|
|
expect(data).toEqual([{ id: 'svc1' }]);
|
|
});
|
|
|
|
it('returns [] and recreates file on ENOENT', async () => {
|
|
const err = new Error('ENOENT');
|
|
err.code = 'ENOENT';
|
|
fs.promises.readFile.mockRejectedValue(err);
|
|
fs.existsSync.mockReturnValue(false);
|
|
|
|
const data = await sm.read();
|
|
expect(data).toEqual([]);
|
|
});
|
|
|
|
it('throws on invalid JSON', async () => {
|
|
fs.promises.readFile.mockResolvedValue('{bad json}');
|
|
await expect(sm.read()).rejects.toThrow('Failed to read state file');
|
|
});
|
|
});
|
|
|
|
describe('write', () => {
|
|
it('acquires lock, writes JSON atomically (tmp+rename), releases lock', async () => {
|
|
const releaseFn = jest.fn().mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(releaseFn);
|
|
|
|
await sm.write([{ id: 'new' }]);
|
|
|
|
expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object));
|
|
// DC-170 atomic contract: tmp file write + rename over the live file
|
|
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
|
expect.stringContaining(`${TEST_PATH}.tmp-`),
|
|
JSON.stringify([{ id: 'new' }], null, 2),
|
|
'utf8'
|
|
);
|
|
expect(fs.promises.rename).toHaveBeenCalledWith(
|
|
expect.stringContaining(`${TEST_PATH}.tmp-`),
|
|
TEST_PATH
|
|
);
|
|
expect(releaseFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws on ELOCKED', async () => {
|
|
const err = new Error('locked');
|
|
err.code = 'ELOCKED';
|
|
lockfile.lock.mockRejectedValue(err);
|
|
|
|
await expect(sm.write([])).rejects.toThrow('locked by another process');
|
|
});
|
|
|
|
it('releases lock even on write error', async () => {
|
|
const releaseFn = jest.fn().mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(releaseFn);
|
|
fs.promises.writeFile.mockRejectedValue(new Error('disk full'));
|
|
|
|
await expect(sm.write([])).rejects.toThrow();
|
|
expect(releaseFn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('atomic read-modify-write cycle', async () => {
|
|
const releaseFn = jest.fn().mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(releaseFn);
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
|
|
|
const result = await sm.update(items => {
|
|
items.push({ id: '2' });
|
|
return items;
|
|
});
|
|
|
|
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
|
expect(fs.promises.writeFile).toHaveBeenCalled();
|
|
expect(releaseFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes current data to updateFn', async () => {
|
|
const releaseFn = jest.fn().mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(releaseFn);
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }]));
|
|
|
|
const updateFn = jest.fn(data => data);
|
|
await sm.update(updateFn);
|
|
|
|
expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]);
|
|
});
|
|
|
|
it('throws on ELOCKED', async () => {
|
|
const err = new Error('locked');
|
|
err.code = 'ELOCKED';
|
|
lockfile.lock.mockRejectedValue(err);
|
|
|
|
await expect(sm.update(d => d)).rejects.toThrow('locked by another process');
|
|
});
|
|
});
|
|
|
|
describe('convenience methods', () => {
|
|
beforeEach(() => {
|
|
const releaseFn = jest.fn().mockResolvedValue();
|
|
lockfile.lock.mockResolvedValue(releaseFn);
|
|
});
|
|
|
|
it('addItem appends to array', async () => {
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
|
const result = await sm.addItem({ id: '2', name: 'New' });
|
|
expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]);
|
|
});
|
|
|
|
it('removeItem filters by id', async () => {
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }]));
|
|
const result = await sm.removeItem('1');
|
|
expect(result).toEqual([{ id: '2' }]);
|
|
});
|
|
|
|
it('updateItem merges updates for matching id', async () => {
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }]));
|
|
const result = await sm.updateItem('1', { name: 'New', port: 8080 });
|
|
expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]);
|
|
});
|
|
|
|
it('findItem returns matching item or null', async () => {
|
|
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }]));
|
|
const found = await sm.findItem('1');
|
|
expect(found).toEqual({ id: '1', name: 'Found' });
|
|
|
|
const missing = await sm.findItem('999');
|
|
expect(missing).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isLocked', () => {
|
|
it('returns lockfile.check result', async () => {
|
|
lockfile.check.mockResolvedValue(true);
|
|
expect(await sm.isLocked()).toBe(true);
|
|
|
|
lockfile.check.mockResolvedValue(false);
|
|
expect(await sm.isLocked()).toBe(false);
|
|
});
|
|
|
|
it('returns false on error', async () => {
|
|
lockfile.check.mockRejectedValue(new Error('fail'));
|
|
expect(await sm.isLocked()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('forceUnlock', () => {
|
|
it('calls lockfile.unlock', async () => {
|
|
await sm.forceUnlock();
|
|
expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH);
|
|
});
|
|
|
|
it('ignores ENOTACQUIRED error', async () => {
|
|
const err = new Error('not locked');
|
|
err.code = 'ENOTACQUIRED';
|
|
lockfile.unlock.mockRejectedValue(err);
|
|
await expect(sm.forceUnlock()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('throws other errors', async () => {
|
|
lockfile.unlock.mockRejectedValue(new Error('other'));
|
|
await expect(sm.forceUnlock()).rejects.toThrow('other');
|
|
});
|
|
});
|
|
});
|