// 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()); }); }); });