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