test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
291
dashcaddy-api/__tests__/auth-manager.test.js
Normal file
291
dashcaddy-api/__tests__/auth-manager.test.js
Normal file
@@ -0,0 +1,291 @@
|
||||
// Must mock crypto-utils BEFORE auth-manager is required,
|
||||
// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey()
|
||||
const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad');
|
||||
jest.mock('../crypto-utils', () => ({
|
||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||
}));
|
||||
|
||||
jest.mock('../credential-manager', () => ({
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const authManager = require('../auth-manager');
|
||||
const credentialManager = require('../credential-manager');
|
||||
|
||||
describe('AuthManager', () => {
|
||||
beforeEach(() => {
|
||||
authManager.clearCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('JWT Generation and Verification', () => {
|
||||
it('generateJWT returns a valid JWT string', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.split('.')).toHaveLength(3); // header.payload.signature
|
||||
});
|
||||
|
||||
it('generateJWT defaults scope to [read, write]', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result.scope).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('generateJWT respects custom scope', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1', scope: ['admin'] });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result.scope).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('generateJWT throws if payload.sub missing', async () => {
|
||||
await expect(authManager.generateJWT({ name: 'test' }))
|
||||
.rejects.toThrow('must include "sub"');
|
||||
});
|
||||
|
||||
it('generateJWT respects custom expiresIn', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' }, '1s');
|
||||
// Token should be valid immediately
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns decoded payload for valid token', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.userId).toBe('user1');
|
||||
expect(result.scope).toEqual(['read', 'write']);
|
||||
expect(result.iat).toBeDefined();
|
||||
expect(result.exp).toBeDefined();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for expired token', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' }, '0s');
|
||||
// Wait a tick for expiration
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for invalid token', async () => {
|
||||
const result = await authManager.verifyJWT('garbage.not.ajwt');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for token signed with different secret', async () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const fakeToken = jwt.sign({ sub: 'user1' }, 'wrong-secret');
|
||||
const result = await authManager.verifyJWT(fakeToken);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Generation', () => {
|
||||
it('generateAPIKey returns key in dk_<id>_<secret> format', async () => {
|
||||
const result = await authManager.generateAPIKey('My Key');
|
||||
expect(result.key).toMatch(/^dk_[a-f0-9]+_[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it('generateAPIKey stores SHA-256 hash via credentialManager', async () => {
|
||||
const result = await authManager.generateAPIKey('Test Key');
|
||||
expect(credentialManager.store).toHaveBeenCalledWith(
|
||||
expect.stringContaining('auth.apikey.'),
|
||||
expect.any(String) // SHA-256 hash
|
||||
);
|
||||
});
|
||||
|
||||
it('generateAPIKey stores metadata separately', async () => {
|
||||
await authManager.generateAPIKey('Named Key', ['read']);
|
||||
// Second call should be metadata
|
||||
const metaCalls = credentialManager.store.mock.calls.filter(
|
||||
call => call[0].startsWith('auth.metadata.')
|
||||
);
|
||||
expect(metaCalls.length).toBe(1);
|
||||
const metadata = JSON.parse(metaCalls[0][1]);
|
||||
expect(metadata.name).toBe('Named Key');
|
||||
expect(metadata.scopes).toEqual(['read']);
|
||||
});
|
||||
|
||||
it('generateAPIKey returns id, name, scopes, createdAt', async () => {
|
||||
const result = await authManager.generateAPIKey('Full Key', ['read', 'write']);
|
||||
expect(result).toHaveProperty('key');
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.name).toBe('Full Key');
|
||||
expect(result.scopes).toEqual(['read', 'write']);
|
||||
expect(result.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('generateAPIKey throws if name missing', async () => {
|
||||
await expect(authManager.generateAPIKey('')).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('generateAPIKey caches metadata', async () => {
|
||||
const result = await authManager.generateAPIKey('Cached Key');
|
||||
expect(authManager.keyMetadataCache.has(result.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Verification', () => {
|
||||
let testKey;
|
||||
let testKeyId;
|
||||
let testHash;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Generate a key for verification tests
|
||||
const generated = await authManager.generateAPIKey('Verify Test');
|
||||
testKey = generated.key;
|
||||
testKeyId = generated.id;
|
||||
testHash = crypto.createHash('sha256').update(testKey).digest('hex');
|
||||
|
||||
// Set up credentialManager to return the hash and metadata
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key === `auth.apikey.${testKeyId}`) return testHash;
|
||||
if (key === `auth.metadata.${testKeyId}`) {
|
||||
return JSON.stringify({ id: testKeyId, name: 'Verify Test', scopes: ['read', 'write'] });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns keyId, scopes, name for valid key', async () => {
|
||||
// Clear cache to force credential lookup
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.keyId).toBe(testKeyId);
|
||||
expect(result.scopes).toEqual(['read', 'write']);
|
||||
expect(result.name).toBe('Verify Test');
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null for key not starting with dk_', async () => {
|
||||
const result = await authManager.verifyAPIKey('invalid_prefix_key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null for key with wrong part count', async () => {
|
||||
const result = await authManager.verifyAPIKey('dk_only_two');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null when stored hash not found', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(null);
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(`dk_${testKeyId}_wrongsecret`);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null on hash mismatch', async () => {
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key.startsWith('auth.apikey.')) return 'wrong-hash-value-that-does-not-match';
|
||||
return null;
|
||||
});
|
||||
authManager.clearCache();
|
||||
// The hash comparison will fail because hashes have different lengths
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null when metadata not found', async () => {
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key.startsWith('auth.apikey.')) return testHash;
|
||||
return null; // No metadata
|
||||
});
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Revocation', () => {
|
||||
it('revokeAPIKey deletes hash and metadata', async () => {
|
||||
await authManager.revokeAPIKey('abc123');
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('auth.apikey.abc123');
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('auth.metadata.abc123');
|
||||
});
|
||||
|
||||
it('revokeAPIKey removes from cache', async () => {
|
||||
authManager.keyMetadataCache.set('abc123', { name: 'test' });
|
||||
await authManager.revokeAPIKey('abc123');
|
||||
expect(authManager.keyMetadataCache.has('abc123')).toBe(false);
|
||||
});
|
||||
|
||||
it('revokeAPIKey returns true on success', async () => {
|
||||
const result = await authManager.revokeAPIKey('test');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('revokeAPIKey returns false on error', async () => {
|
||||
credentialManager.delete.mockRejectedValueOnce(new Error('fail'));
|
||||
const result = await authManager.revokeAPIKey('fail-key');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Listing', () => {
|
||||
it('listAPIKeys returns metadata for all keys', async () => {
|
||||
credentialManager.list.mockResolvedValue([
|
||||
'auth.metadata.key1',
|
||||
'auth.metadata.key2',
|
||||
'auth.apikey.key1',
|
||||
'auth.apikey.key2'
|
||||
]);
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key === 'auth.metadata.key1') return JSON.stringify({ id: 'key1', name: 'Key 1' });
|
||||
if (key === 'auth.metadata.key2') return JSON.stringify({ id: 'key2', name: 'Key 2' });
|
||||
return null;
|
||||
});
|
||||
|
||||
const keys = await authManager.listAPIKeys();
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0].name).toBe('Key 1');
|
||||
expect(keys[1].name).toBe('Key 2');
|
||||
});
|
||||
|
||||
it('listAPIKeys returns empty array on error', async () => {
|
||||
credentialManager.list.mockRejectedValue(new Error('fail'));
|
||||
const keys = await authManager.listAPIKeys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key Metadata', () => {
|
||||
it('getKeyMetadata returns from cache when available', async () => {
|
||||
authManager.keyMetadataCache.set('cached', { name: 'Cached' });
|
||||
const result = await authManager.getKeyMetadata('cached');
|
||||
expect(result.name).toBe('Cached');
|
||||
expect(credentialManager.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getKeyMetadata fetches from credentialManager when not cached', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'x', name: 'Fetched' }));
|
||||
const result = await authManager.getKeyMetadata('x');
|
||||
expect(result.name).toBe('Fetched');
|
||||
expect(credentialManager.retrieve).toHaveBeenCalledWith('auth.metadata.x');
|
||||
});
|
||||
|
||||
it('getKeyMetadata caches fetched result', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'y', name: 'Cached Now' }));
|
||||
await authManager.getKeyMetadata('y');
|
||||
expect(authManager.keyMetadataCache.has('y')).toBe(true);
|
||||
});
|
||||
|
||||
it('getKeyMetadata returns null when not found', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(null);
|
||||
const result = await authManager.getKeyMetadata('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache', () => {
|
||||
it('clearCache empties keyMetadataCache', () => {
|
||||
authManager.keyMetadataCache.set('a', { name: 'A' });
|
||||
authManager.keyMetadataCache.set('b', { name: 'B' });
|
||||
authManager.clearCache();
|
||||
expect(authManager.keyMetadataCache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user