DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Credential Manager for DashCaddy
|
||||
* Unified interface for secure credential storage
|
||||
* Uses OS keychain when available, falls back to encrypted file storage
|
||||
*/
|
||||
|
||||
const keychainManager = require('../security/keychain-manager');
|
||||
const cryptoUtils = require('../security/crypto-utils');
|
||||
const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Resolve credentials file path — supports both standard install (/app/credentials.json)
|
||||
// and custom deployments with consolidated data directory (/app/data/credentials.json)
|
||||
function resolveCredentialsFile() {
|
||||
if (process.env.CREDENTIALS_FILE) {
|
||||
return process.env.CREDENTIALS_FILE;
|
||||
}
|
||||
const candidates = [
|
||||
path.join(__dirname, 'credentials.json'),
|
||||
path.join(__dirname, 'data', 'credentials.json'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No existing file — return standard path so first store() creates it there
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const CREDENTIALS_FILE = resolveCredentialsFile();
|
||||
|
||||
class CredentialManager {
|
||||
constructor() {
|
||||
this.useKeychain = keychainManager.available;
|
||||
this.cache = new Map(); // In-memory cache with TTL
|
||||
this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
this.lockOptions = {
|
||||
retries: { retries: 10, minTimeout: 100, maxTimeout: 300 },
|
||||
stale: 30000
|
||||
};
|
||||
|
||||
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential securely
|
||||
* @param {string} key - Credential identifier (e.g., 'dns.token', 'cloudflare.apikey')
|
||||
* @param {string} value - Credential value
|
||||
* @param {Object} metadata - Optional metadata (non-sensitive)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async store(key, value, metadata = {}) {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!key || typeof key !== 'string') {
|
||||
throw new Error('Credential key is required');
|
||||
}
|
||||
if (!value || typeof value !== 'string') {
|
||||
throw new Error('Credential value is required');
|
||||
}
|
||||
|
||||
// Try OS keychain first
|
||||
if (this.useKeychain) {
|
||||
const success = await keychainManager.store(key, value);
|
||||
if (success) {
|
||||
// Store metadata separately in file
|
||||
await this.storeMetadata(key, metadata);
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
console.log(`[CredentialManager] Stored '${key}' in OS keychain`);
|
||||
return true;
|
||||
}
|
||||
console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`);
|
||||
}
|
||||
|
||||
// Fallback to encrypted file storage
|
||||
await this.storeInFile(key, value, metadata);
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
console.log(`[CredentialManager] Stored '${key}' in encrypted file`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to store '${key}':`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a credential
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<string|null>} Credential value or null
|
||||
*/
|
||||
async retrieve(key) {
|
||||
try {
|
||||
// Check cache first (with TTL expiration)
|
||||
if (this.cache.has(key)) {
|
||||
const cached = this.cache.get(key);
|
||||
if (Date.now() < cached.exp) {
|
||||
return cached.value;
|
||||
}
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
// Try OS keychain first
|
||||
if (this.useKeychain) {
|
||||
const value = await keychainManager.retrieve(key);
|
||||
if (value) {
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to encrypted file storage
|
||||
const value = await this.retrieveFromFile(key);
|
||||
if (value) {
|
||||
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a credential
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async delete(key) {
|
||||
try {
|
||||
// Remove from cache
|
||||
this.cache.delete(key);
|
||||
|
||||
// Try OS keychain
|
||||
if (this.useKeychain) {
|
||||
await keychainManager.delete(key);
|
||||
}
|
||||
|
||||
// Remove from file storage
|
||||
await this.deleteFromFile(key);
|
||||
|
||||
console.log(`[CredentialManager] Deleted '${key}'`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[CredentialManager] Failed to delete '${key}':`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stored credential keys (not values)
|
||||
* @returns {Promise<Array<string>>} Array of credential keys
|
||||
*/
|
||||
async list() {
|
||||
try {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
return Object.keys(credentials);
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to list credentials:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a credential
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<Object|null>} Metadata object or null
|
||||
*/
|
||||
async getMetadata(key) {
|
||||
try {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
return credentials[key]?.metadata || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key (re-encrypt all credentials with new key)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async rotateEncryptionKey() {
|
||||
let release;
|
||||
try {
|
||||
console.log('[CredentialManager] Starting encryption key rotation...');
|
||||
|
||||
// Ensure file exists before locking
|
||||
this._ensureFileExists();
|
||||
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
|
||||
|
||||
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
||||
const credentials = JSON.parse(data);
|
||||
const keys = Object.keys(credentials);
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('[CredentialManager] No credentials to rotate');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decrypt all values with the CURRENT key first
|
||||
const decryptedEntries = {};
|
||||
for (const key of keys) {
|
||||
const value = credentials[key].value;
|
||||
decryptedEntries[key] = {
|
||||
plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value,
|
||||
metadata: credentials[key].metadata
|
||||
};
|
||||
}
|
||||
|
||||
// Generate new key (this replaces the cached key and saves to disk)
|
||||
const { oldKey } = cryptoUtils.rotateKey();
|
||||
|
||||
// Re-encrypt all credentials with the new key
|
||||
const rotated = {};
|
||||
for (const key of keys) {
|
||||
rotated[key] = {
|
||||
value: cryptoUtils.encrypt(decryptedEntries[key].plaintext),
|
||||
metadata: decryptedEntries[key].metadata,
|
||||
rotatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Save with new encryption
|
||||
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(rotated, null, 2), { mode: 0o600 });
|
||||
|
||||
// Clear cache to force reload
|
||||
this.cache.clear();
|
||||
|
||||
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Key rotation failed:', error.message);
|
||||
return false;
|
||||
} finally {
|
||||
if (release) {
|
||||
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate plaintext credentials to encrypted format
|
||||
* @returns {Promise<Object>} Migration results
|
||||
*/
|
||||
async migrateToEncrypted() {
|
||||
try {
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
await this._lockedUpdate(credentials => {
|
||||
for (const [key, data] of Object.entries(credentials)) {
|
||||
if (!cryptoUtils.isEncrypted(data.value)) {
|
||||
credentials[key].value = cryptoUtils.encrypt(data.value);
|
||||
credentials[key].migratedAt = new Date().toISOString();
|
||||
migrated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return credentials;
|
||||
});
|
||||
|
||||
if (migrated > 0) {
|
||||
this.cache.clear();
|
||||
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
|
||||
}
|
||||
|
||||
return { migrated, skipped, total: migrated + skipped };
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
/**
|
||||
* Ensure credentials file exists (needed before locking)
|
||||
* @private
|
||||
*/
|
||||
_ensureFileExists() {
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
||||
const dir = path.dirname(CREDENTIALS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CREDENTIALS_FILE, '{}', { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write with file locking
|
||||
* @param {Function} updateFn - Receives current credentials object, returns updated object
|
||||
* @returns {Promise<Object>} Updated credentials
|
||||
* @private
|
||||
*/
|
||||
async _lockedUpdate(updateFn) {
|
||||
this._ensureFileExists();
|
||||
let release;
|
||||
try {
|
||||
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
|
||||
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
||||
const credentials = JSON.parse(data);
|
||||
const updated = await updateFn(credentials);
|
||||
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOCKED') {
|
||||
throw new Error('Credentials file is locked by another process. Try again.');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (release) {
|
||||
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async storeInFile(key, value, metadata) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
credentials[key] = {
|
||||
value: cryptoUtils.encrypt(value),
|
||||
metadata,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return credentials;
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveFromFile(key) {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
const data = credentials[key];
|
||||
if (!data) return null;
|
||||
|
||||
return cryptoUtils.isEncrypted(data.value)
|
||||
? cryptoUtils.decrypt(data.value)
|
||||
: data.value;
|
||||
}
|
||||
|
||||
async deleteFromFile(key) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
delete credentials[key];
|
||||
return credentials;
|
||||
});
|
||||
}
|
||||
|
||||
async storeMetadata(key, metadata) {
|
||||
await this._lockedUpdate(credentials => {
|
||||
if (!credentials[key]) {
|
||||
credentials[key] = { metadata };
|
||||
} else {
|
||||
credentials[key].metadata = metadata;
|
||||
}
|
||||
credentials[key].updatedAt = new Date().toISOString();
|
||||
return credentials;
|
||||
});
|
||||
}
|
||||
|
||||
async loadCredentialsFile() {
|
||||
try {
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
||||
return {};
|
||||
}
|
||||
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to load credentials file:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export credentials for backup (encrypted)
|
||||
* @returns {Promise<string>} Encrypted backup data
|
||||
*/
|
||||
async exportBackup() {
|
||||
const credentials = await this.loadCredentialsFile();
|
||||
const backup = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials
|
||||
};
|
||||
return cryptoUtils.encrypt(JSON.stringify(backup));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import credentials from backup
|
||||
* @param {string} encryptedBackup - Encrypted backup data
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async importBackup(encryptedBackup) {
|
||||
try {
|
||||
const decrypted = cryptoUtils.decrypt(encryptedBackup);
|
||||
const backup = JSON.parse(decrypted);
|
||||
|
||||
if (backup.version !== '1.0') {
|
||||
throw new Error('Unsupported backup version');
|
||||
}
|
||||
|
||||
await this._lockedUpdate(() => backup.credentials);
|
||||
this.cache.clear();
|
||||
|
||||
console.log('[CredentialManager] Successfully imported backup');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CredentialManager] Failed to import backup:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
module.exports = new CredentialManager();
|
||||
Reference in New Issue
Block a user