Files
dashcaddy/dashcaddy-api/__tests__/credential-manager.test.js
T
Hermes 5efacd11e8
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
fix(security): close rotateEncryptionKey crash window with in-process key rollback (DC-107) [glm-grade=A]
If the atomicWriteJSON of rotated credentials failed after rotateKey() had
already persisted+cached the new key, the on-disk key could no longer decrypt
the on-disk credentials.json (permanent loss on restart). Catch-path now
restores the old key via new cryptoUtils.restoreKey() (canonical atomic-write,
0600, hex-validated) while still holding the proper-lockfile. Double-failure
(rollback throws) is contained and the lock is still released. Hard-crash
mid-rollback is covered by the existing .bak startup fallback.

Judge: GLM-4.6 stand-in, round-1 grade A, 0 blocking, 1 LOW polish (folded).
URN urn:ump:z2sz3x6abtcffpqyde2l2ssq34vy47t5h2gbmkr3wtmfxwkerinq
Tests: 121 suites / 2785 green (6 consecutive runs pre-fold; suite re-run post-fold).
2026-08-23 04:40:41 -07:00

545 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Mock dependencies before requiring the module
jest.mock('../src/security/keychain-manager', () => ({
available: false,
store: jest.fn().mockResolvedValue(false),
retrieve: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(true),
}));
jest.mock('../src/security/crypto-utils', () => ({
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
decrypt: jest.fn(data => {
const parts = data.split(':');
return Buffer.from(parts[2], 'base64').toString('utf8');
}),
isEncrypted: jest.fn(data => typeof data === 'string' && data.startsWith('enc:')),
loadOrCreateKey: jest.fn(() => Buffer.alloc(32, 'k')),
rotateKey: jest.fn(() => ({ oldKey: Buffer.alloc(32, 'k'), newKey: Buffer.alloc(32, 'n') })),
// DC-107 rollback support restore old key in-process after a failed write
restoreKey: jest.fn(() => true),
}));
jest.mock('proper-lockfile', () => ({
lock: jest.fn().mockResolvedValue(jest.fn().mockResolvedValue()),
unlock: jest.fn().mockResolvedValue(),
check: jest.fn().mockResolvedValue(false),
}));
// DC-106: fd-level mock exercising the canonical atomic-write path
// (openSync('wx') -> writeSync -> fsyncSync -> closeSync -> renameSync).
const mockFsState = {
files: {}, // path -> content (destination state after rename)
fdMap: new Map(), // open fd -> { p, content }
closedTmp: new Map(), // closed-but-not-yet-renamed tmp path -> content
openedWith: [], // { p, flags, mode } per openSync call
nextFd: 0,
};
jest.mock('fs', () => ({
existsSync: jest.fn((p) => mockFsState.files[p] !== undefined),
readFileSync: jest.fn((p) => {
if (mockFsState.files[p] === undefined) {
const e = new Error(`ENOENT: ${p}`);
e.code = 'ENOENT';
throw e;
}
return mockFsState.files[p];
}),
mkdirSync: jest.fn(),
// DC-105/DC-106 canonical atomic-write path (atomic-write.js).
openSync: jest.fn((p, flags, mode) => {
mockFsState.openedWith.push({ p, flags, mode });
mockFsState.nextFd += 1;
mockFsState.fdMap.set(mockFsState.nextFd, { p, content: '' });
return mockFsState.nextFd;
}),
writeSync: jest.fn((fd, content) => {
const rec = mockFsState.fdMap.get(fd);
if (!rec) throw new Error(`EBADF: fd ${fd}`);
rec.content += content;
}),
fsyncSync: jest.fn(),
closeSync: jest.fn((fd) => {
const rec = mockFsState.fdMap.get(fd);
if (rec) {
mockFsState.closedTmp.set(rec.p, rec.content);
mockFsState.fdMap.delete(fd);
}
}),
renameSync: jest.fn((src, dst) => {
const content = mockFsState.closedTmp.has(src)
? mockFsState.closedTmp.get(src)
: mockFsState.files[src];
mockFsState.files[dst] = content;
mockFsState.closedTmp.delete(src);
delete mockFsState.files[src];
}),
unlinkSync: jest.fn(),
}));
// DC-106: mirror the production path resolution so assertions read the same
// destination the manager writes to, regardless of env overrides.
const path = require('path');
const platformPaths = require('../platform-paths');
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE
|| path.join(platformPaths.dataDir, 'credentials.json');
describe('CredentialManager', () => {
let credentialManager;
let fs, lockfile, keychainManager, cryptoUtils;
beforeEach(() => {
jest.resetModules();
// Re-get mocked modules
fs = require('fs');
lockfile = require('proper-lockfile');
keychainManager = require('../src/security/keychain-manager');
cryptoUtils = require('../src/security/crypto-utils');
// Reset mock implementations and fd-level atomic-write state
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
mockFsState.fdMap.clear();
mockFsState.closedTmp.clear();
mockFsState.openedWith.length = 0;
mockFsState.nextFd = 0;
// Default world: credentials.json exists with empty payload (the previous
// mock's existsSync=true / readFileSync='{}' semantics, now truthful).
mockFsState.files[CREDENTIALS_FILE] = '{}';
fs.existsSync.mockImplementation((p) => mockFsState.files[p] !== undefined);
fs.readFileSync.mockImplementation((p) => {
if (mockFsState.files[p] === undefined) {
const e = new Error(`ENOENT: ${p}`);
e.code = 'ENOENT';
throw e;
}
return mockFsState.files[p];
});
fs.openSync.mockClear();
fs.writeSync.mockClear();
fs.fsyncSync.mockClear();
fs.closeSync.mockClear();
fs.renameSync.mockClear();
fs.unlinkSync.mockClear();
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
keychainManager.available = false;
credentialManager = require('../src/managers/credential-manager');
credentialManager.cache.clear();
});
describe('store', () => {
it('stores value in encrypted file when keychain unavailable', async () => {
const result = await credentialManager.store('test.key', 'secret-value');
expect(result).toBe(true);
expect(cryptoUtils.encrypt).toHaveBeenCalledWith('secret-value');
expect(fs.renameSync).toHaveBeenCalled(); // DC-106: canonical write landed
});
it('stores value in keychain when available', async () => {
keychainManager.available = true;
// Need to get a fresh instance that sees available=true
jest.resetModules();
fs = require('fs');
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
mockFsState.fdMap.clear();
mockFsState.closedTmp.clear();
mockFsState.openedWith.length = 0;
lockfile = require('proper-lockfile');
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
keychainManager = require('../src/security/keychain-manager');
keychainManager.available = true;
keychainManager.store.mockResolvedValue(true);
credentialManager = require('../src/managers/credential-manager');
const result = await credentialManager.store('test.key', 'value');
expect(result).toBe(true);
expect(keychainManager.store).toHaveBeenCalledWith('test.key', 'value');
});
it('falls back to file if keychain store fails', async () => {
keychainManager.available = true;
jest.resetModules();
fs = require('fs');
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
mockFsState.fdMap.clear();
mockFsState.closedTmp.clear();
mockFsState.openedWith.length = 0;
lockfile = require('proper-lockfile');
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
keychainManager = require('../src/security/keychain-manager');
keychainManager.available = true;
keychainManager.store.mockResolvedValue(false);
cryptoUtils = require('../src/security/crypto-utils');
credentialManager = require('../src/managers/credential-manager');
const result = await credentialManager.store('test.key', 'value');
expect(result).toBe(true);
expect(cryptoUtils.encrypt).toHaveBeenCalled();
});
it('rejects empty key', async () => {
const result = await credentialManager.store('', 'value');
expect(result).toBe(false);
});
it('rejects empty value', async () => {
const result = await credentialManager.store('key', '');
expect(result).toBe(false);
});
it('updates cache after storing', async () => {
await credentialManager.store('test.key', 'cached-value');
expect(credentialManager.cache.has('test.key')).toBe(true);
expect(credentialManager.cache.get('test.key').value).toBe('cached-value');
});
});
describe('retrieve', () => {
it('returns cached value within TTL', async () => {
credentialManager.cache.set('cached.key', {
value: 'cached-val',
exp: Date.now() + 60000
});
const result = await credentialManager.retrieve('cached.key');
expect(result).toBe('cached-val');
});
it('does not return expired cache entry', async () => {
credentialManager.cache.set('expired.key', {
value: 'old-val',
exp: Date.now() - 1000
});
// Set up file to return data
fs.readFileSync.mockReturnValue(JSON.stringify({
'expired.key': { value: 'enc:tag:' + Buffer.from('file-val').toString('base64') }
}));
const result = await credentialManager.retrieve('expired.key');
expect(result).toBe('file-val');
});
it('retrieves from encrypted file as fallback', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify({
'file.key': { value: 'enc:tag:' + Buffer.from('secret').toString('base64') }
}));
const result = await credentialManager.retrieve('file.key');
expect(result).toBe('secret');
});
it('returns null when key not found', async () => {
fs.readFileSync.mockReturnValue('{}');
const result = await credentialManager.retrieve('missing.key');
expect(result).toBeNull();
});
it('returns null on error', async () => {
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockImplementation(() => { throw new Error('fail'); });
const result = await credentialManager.retrieve('broken.key');
expect(result).toBeNull();
});
});
describe('delete', () => {
it('removes from cache, keychain, and file', async () => {
credentialManager.cache.set('del.key', { value: 'x', exp: Date.now() + 60000 });
fs.readFileSync.mockReturnValue(JSON.stringify({ 'del.key': { value: 'x' } }));
const result = await credentialManager.delete('del.key');
expect(result).toBe(true);
expect(credentialManager.cache.has('del.key')).toBe(false);
});
it('returns false on error', async () => {
lockfile.lock.mockRejectedValue(new Error('lock fail'));
const result = await credentialManager.delete('fail.key');
expect(result).toBe(false);
});
});
describe('list', () => {
it('returns all keys from credentials file', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify({
'key1': { value: 'a' },
'key2': { value: 'b' }
}));
const keys = await credentialManager.list();
expect(keys).toEqual(['key1', 'key2']);
});
it('returns empty array on error', async () => {
fs.existsSync.mockReturnValue(false);
const keys = await credentialManager.list();
expect(keys).toEqual([]);
});
});
describe('getMetadata', () => {
it('returns metadata for a credential', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify({
'test.key': { value: 'x', metadata: { provider: 'cloudflare' } }
}));
const meta = await credentialManager.getMetadata('test.key');
expect(meta).toEqual({ provider: 'cloudflare' });
});
it('returns null when key not found', async () => {
fs.readFileSync.mockReturnValue('{}');
const meta = await credentialManager.getMetadata('missing');
expect(meta).toBeNull();
});
});
describe('_lockedUpdate', () => {
it('acquires lock, reads, applies update, writes, releases', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue(JSON.stringify({ a: 1 }));
await credentialManager._lockedUpdate(creds => {
creds.b = 2;
return creds;
});
expect(lockfile.lock).toHaveBeenCalled();
const writtenData = JSON.parse(mockFsState.files[CREDENTIALS_FILE]);
expect(writtenData).toEqual({ a: 1, b: 2 });
expect(releaseFn).toHaveBeenCalled();
});
it('throws on ELOCKED error', async () => {
const error = new Error('locked');
error.code = 'ELOCKED';
lockfile.lock.mockRejectedValue(error);
await expect(credentialManager._lockedUpdate(() => ({}))).rejects.toThrow('locked by another process');
});
it('releases lock even on error', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue('{}');
await expect(
credentialManager._lockedUpdate(() => { throw new Error('update error'); })
).rejects.toThrow('update error');
expect(releaseFn).toHaveBeenCalled();
});
});
describe('rotateEncryptionKey', () => {
it('decrypts all credentials then re-encrypts with new key', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue(JSON.stringify({
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
}));
const result = await credentialManager.rotateEncryptionKey();
expect(result).toBe(true);
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
expect(fs.renameSync).toHaveBeenCalled(); // DC-106: canonical write landed
});
it('clears cache after rotation', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
credentialManager.cache.set('x', { value: 'y', exp: Date.now() + 60000 });
// Must have non-empty credentials so code path reaches cache.clear()
fs.readFileSync.mockReturnValue(JSON.stringify({
'key1': { value: 'enc:tag:' + Buffer.from('val').toString('base64'), metadata: {} }
}));
await credentialManager.rotateEncryptionKey();
expect(credentialManager.cache.size).toBe(0);
});
it('returns false on error', async () => {
lockfile.lock.mockRejectedValue(new Error('nope'));
const result = await credentialManager.rotateEncryptionKey();
expect(result).toBe(false);
// DC-107: failure before rotateKey() must NOT trigger a rollback
expect(cryptoUtils.restoreKey).not.toHaveBeenCalled();
});
it('rolls back the encryption key when the rotated write fails (DC-107)', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue(JSON.stringify({
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
}));
// atomicWriteJSON fails at the rename step, AFTER rotateKey() already
// swapped the on-disk key and in-memory cache
fs.renameSync.mockImplementationOnce(() => { throw new Error('EIO: rename'); });
const result = await credentialManager.rotateEncryptionKey();
expect(result).toBe(false);
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
const expectedOldHex = Buffer.alloc(32, 'k').toString('hex');
expect(cryptoUtils.restoreKey).toHaveBeenCalledTimes(1);
expect(cryptoUtils.restoreKey).toHaveBeenCalledWith(expectedOldHex);
expect(releaseFn).toHaveBeenCalled(); // lock still released
});
it('returns false without crashing when the rollback itself fails (DC-107)', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue(JSON.stringify({
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
}));
fs.renameSync.mockImplementationOnce(() => { throw new Error('EIO: rename'); });
cryptoUtils.restoreKey.mockImplementationOnce(() => { throw new Error('rollback ENOSPC'); });
const result = await credentialManager.rotateEncryptionKey();
expect(result).toBe(false);
expect(cryptoUtils.restoreKey).toHaveBeenCalledTimes(1);
expect(releaseFn).toHaveBeenCalled(); // lock released even on double failure
});
});
describe('exportBackup / importBackup', () => {
it('exportBackup returns encrypted JSON string', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify({ key1: { value: 'x' } }));
const backup = await credentialManager.exportBackup();
expect(cryptoUtils.encrypt).toHaveBeenCalled();
expect(typeof backup).toBe('string');
});
it('importBackup decrypts and replaces credentials', async () => {
const backupData = JSON.stringify({
version: '1.0',
exportedAt: new Date().toISOString(),
credentials: { imported: { value: 'y' } }
});
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
fs.readFileSync.mockReturnValue('{}');
const result = await credentialManager.importBackup(encrypted);
expect(result).toBe(true);
});
it('importBackup rejects unsupported backup version', async () => {
const backupData = JSON.stringify({ version: '2.0', credentials: {} });
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
const result = await credentialManager.importBackup(encrypted);
expect(result).toBe(false);
});
it('importBackup returns false on error', async () => {
cryptoUtils.decrypt.mockImplementationOnce(() => { throw new Error('bad'); });
const result = await credentialManager.importBackup('bad-data');
expect(result).toBe(false);
});
});
describe('DC-106 canonical atomic-write migration', () => {
it('writes credentials.json via wx tmp + fsync + rename, mode 0600', async () => {
await credentialManager.store('dc106.key', 'dc106-secret');
// fsyncDir also openSync()s the parent dir (flags 'r') — filter to the
// payload tmp opens to assert on the canonical write itself.
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
expect(wxOpens.length).toBe(1); // file pre-existed -> no ensure-create
expect(wxOpens[0].mode).toBe(0o600); // sensitive file mode preserved
expect(fs.fsyncSync).toHaveBeenCalled(); // bytes pinned before rename
expect(fs.renameSync).toHaveBeenCalled();
const [tmpSrc, dst] = fs.renameSync.mock.calls
.find((c) => c[1] === CREDENTIALS_FILE);
expect(tmpSrc).not.toBe(dst);
expect(tmpSrc).toMatch(/\.credentials\.json\.tmp-/); // canonical tmp prefix
expect(dst).toBe(CREDENTIALS_FILE);
expect(mockFsState.files[CREDENTIALS_FILE]).toBeDefined();
// No leftover tmp files: every payload tmp was renamed away
const renamedSrcs = fs.renameSync.mock.calls.map((c) => c[0]);
for (const o of wxOpens) {
expect(renamedSrcs).toContain(o.p);
}
});
it('never writes plaintext secret to disk', async () => {
await credentialManager.store('dc106b.key', 'plaintext-canary-9f1a');
const raw = mockFsState.files[CREDENTIALS_FILE];
expect(raw).toBeDefined();
expect(raw).not.toContain('plaintext-canary-9f1a');
expect(raw).toContain('enc:'); // crypto-utils mock prefix
});
it('_lockedUpdate closes fd before rename (torn-write window eliminated)', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
mockFsState.files[CREDENTIALS_FILE] = '{}';
await credentialManager._lockedUpdate((creds) => {
creds.k = { value: 'enc:x' };
return creds;
});
// fd lifecycle: open -> write -> fsync -> close -> rename. The dir
// fsync adds a second openSync/closeSync pair — so assert on counts of
// payload operations and the GLOBAL invocation order, which jest tracks
// across mocks (invocationCallOrder).
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
expect(wxOpens.length).toBe(1); // exactly one payload write
expect(fs.writeSync).toHaveBeenCalledTimes(1); // dir fsync writes nothing
expect(fs.renameSync).toHaveBeenCalledTimes(1);
const fsyncFirst = fs.fsyncSync.mock.invocationCallOrder[0];
const closeFirst = fs.closeSync.mock.invocationCallOrder[0];
const renameFirst = fs.renameSync.mock.invocationCallOrder[0];
expect(fsyncFirst).toBeDefined();
expect(closeFirst).toBeGreaterThan(fsyncFirst); // fsync before close
expect(renameFirst).toBeGreaterThan(closeFirst); // close before rename
expect(mockFsState.files[CREDENTIALS_FILE]).toContain('enc:x');
});
it('_ensureFileExists creates initial {} atomically at 0600 when absent', async () => {
const releaseFn = jest.fn().mockResolvedValue();
lockfile.lock.mockResolvedValue(releaseFn);
delete mockFsState.files[CREDENTIALS_FILE]; // absent on disk
await credentialManager._lockedUpdate((c) => {
c.k = { value: 'enc:x' };
return c;
});
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
expect(wxOpens.length).toBe(2); // ensure-created '{}' + the locked update
expect(wxOpens[0].mode).toBe(0o600);
// The ensure write staged its tmp FIRST and renamed it into place before
// the locked update renamed over it — creation itself was atomic.
expect(fs.renameSync.mock.calls[0][0]).toBe(wxOpens[0].p);
const final = JSON.parse(mockFsState.files[CREDENTIALS_FILE]);
expect(final.k.value).toBe('enc:x');
});
});
describe('cache TTL', () => {
it('cache entries expire after TTL', async () => {
credentialManager.cache.set('ttl.key', {
value: 'val',
exp: Date.now() - 1 // Already expired
});
fs.readFileSync.mockReturnValue('{}');
const result = await credentialManager.retrieve('ttl.key');
expect(result).toBeNull();
expect(credentialManager.cache.has('ttl.key')).toBe(false);
});
it('new store refreshes cache TTL', async () => {
await credentialManager.store('fresh.key', 'val');
const cached = credentialManager.cache.get('fresh.key');
expect(cached.exp).toBeGreaterThan(Date.now());
});
});
});