Files
dashcaddy/dashcaddy-api/__tests__/auth-manager.test.js
Sami ea5acfa9a2 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>
2026-04-06 21:36:46 -07:00

292 lines
11 KiB
JavaScript

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