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).
545 lines
22 KiB
JavaScript
545 lines
22 KiB
JavaScript
// 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());
|
||
});
|
||
});
|
||
});
|