From d15c1601859418dce3ee91a289aa983d72b3b668 Mon Sep 17 00:00:00 2001 From: Sami Date: Fri, 20 Mar 2026 22:19:45 -0700 Subject: [PATCH] test: add comprehensive auth-manager test suite (Phase 3 WIP) --- dashcaddy-api/__tests__/auth-manager.test.js | 367 +++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 dashcaddy-api/__tests__/auth-manager.test.js diff --git a/dashcaddy-api/__tests__/auth-manager.test.js b/dashcaddy-api/__tests__/auth-manager.test.js new file mode 100644 index 0000000..f76c0a4 --- /dev/null +++ b/dashcaddy-api/__tests__/auth-manager.test.js @@ -0,0 +1,367 @@ +/** + * @jest-environment node + * Comprehensive tests for auth-manager.js + * Tests JWT generation/validation, API key management, and security boundaries + */ + +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const AuthManager = require('../auth-manager'); +const credentialManager = require('../credential-manager'); + +// Mock credential manager +jest.mock('../credential-manager'); +jest.mock('../logger-utils', () => ({ + safeLog: jest.fn() +})); + +describe('AuthManager', () => { + let authManager; + + beforeEach(() => { + authManager = new AuthManager(); + jest.clearAllMocks(); + credentialManager.save.mockResolvedValue(true); + credentialManager.get.mockResolvedValue(null); + credentialManager.delete.mockResolvedValue(true); + credentialManager.list.mockResolvedValue([]); + }); + + describe('JWT Generation', () => { + test('should generate valid JWT token', async () => { + const payload = { sub: 'user123', role: 'admin' }; + const token = await authManager.generateJWT(payload); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + test('should include required claims in JWT', async () => { + const payload = { sub: 'user123', role: 'admin' }; + const token = await authManager.generateJWT(payload); + const decoded = jwt.decode(token); + + expect(decoded.sub).toBe('user123'); + expect(decoded.role).toBe('admin'); + expect(decoded.iat).toBeDefined(); + expect(decoded.exp).toBeDefined(); + expect(decoded.scope).toEqual(['read', 'write']); // default scopes + }); + + test('should respect custom expiration time', async () => { + const payload = { sub: 'user123' }; + const token = await authManager.generateJWT(payload, '1h'); + const decoded = jwt.decode(token); + + const expectedExp = decoded.iat + 3600; // 1 hour = 3600 seconds + expect(decoded.exp).toBeCloseTo(expectedExp, -1); // Allow 1 sec tolerance + }); + + test('should include custom scopes', async () => { + const payload = { sub: 'user123', scope: ['read'] }; + const token = await authManager.generateJWT(payload); + const decoded = jwt.decode(token); + + expect(decoded.scope).toEqual(['read']); + }); + + test('should reject JWT generation without sub claim', async () => { + const payload = { role: 'admin' }; // Missing sub + await expect(authManager.generateJWT(payload)) + .rejects.toThrow('JWT payload must include "sub"'); + }); + + test('should reject JWT generation with null payload', async () => { + await expect(authManager.generateJWT(null)) + .rejects.toThrow(); + }); + }); + + describe('JWT Verification', () => { + test('should verify valid JWT token', async () => { + const payload = { sub: 'user123', role: 'admin' }; + const token = await authManager.generateJWT(payload); + const verified = await authManager.verifyJWT(token); + + expect(verified).toBeDefined(); + expect(verified.userId).toBe('user123'); + expect(verified.scope).toEqual(['read', 'write']); + expect(verified.iat).toBeDefined(); + expect(verified.exp).toBeDefined(); + }); + + test('should reject invalid JWT token', async () => { + const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature'; + const verified = await authManager.verifyJWT(invalidToken); + + expect(verified).toBeNull(); + }); + + test('should reject malformed JWT token', async () => { + const verified = await authManager.verifyJWT('not-a-jwt-token'); + expect(verified).toBeNull(); + }); + + test('should reject expired JWT token', async () => { + const payload = { sub: 'user123' }; + const token = await authManager.generateJWT(payload, '-1s'); // Already expired + + // Wait a tiny bit to ensure expiration + await new Promise(resolve => setTimeout(resolve, 100)); + + const verified = await authManager.verifyJWT(token); + expect(verified).toBeNull(); + }); + + test('should reject JWT with wrong signature', async () => { + const payload = { sub: 'user123' }; + const wrongSecret = 'wrong-secret-key'; + const token = jwt.sign(payload, wrongSecret); + + const verified = await authManager.verifyJWT(token); + expect(verified).toBeNull(); + }); + + test('should handle empty token gracefully', async () => { + const verified = await authManager.verifyJWT(''); + expect(verified).toBeNull(); + }); + + test('should handle null token gracefully', async () => { + const verified = await authManager.verifyJWT(null); + expect(verified).toBeNull(); + }); + }); + + describe('API Key Generation', () => { + test('should generate valid API key', async () => { + const result = await authManager.generateAPIKey('test-key'); + + expect(result).toBeDefined(); + expect(result.key).toMatch(/^dk_[a-f0-9]{32}_[a-f0-9]{64}$/); + expect(result.id).toMatch(/^[a-f0-9]{32}$/); + expect(result.name).toBe('test-key'); + expect(result.scopes).toEqual(['read', 'write']); + expect(result.createdAt).toBeDefined(); + }); + + test('should generate unique API keys', async () => { + const key1 = await authManager.generateAPIKey('key1'); + const key2 = await authManager.generateAPIKey('key2'); + + expect(key1.key).not.toBe(key2.key); + expect(key1.id).not.toBe(key2.id); + }); + + test('should accept custom scopes', async () => { + const result = await authManager.generateAPIKey('readonly-key', ['read']); + + expect(result.scopes).toEqual(['read']); + }); + + test('should store API key in credential manager', async () => { + await authManager.generateAPIKey('test-key'); + + expect(credentialManager.save).toHaveBeenCalledWith( + expect.stringMatching(/^auth\.apikey\./), + expect.objectContaining({ + keySecret: expect.any(String) + }) + ); + }); + + test('should store API key metadata', async () => { + await authManager.generateAPIKey('test-key', ['read']); + + expect(credentialManager.save).toHaveBeenCalledWith( + expect.stringMatching(/^auth\.metadata\./), + expect.objectContaining({ + name: 'test-key', + scopes: ['read'], + createdAt: expect.any(String) + }) + ); + }); + + test('should reject API key generation without name', async () => { + await expect(authManager.generateAPIKey('')) + .rejects.toThrow('API key name is required'); + + await expect(authManager.generateAPIKey(null)) + .rejects.toThrow('API key name is required'); + + await expect(authManager.generateAPIKey(undefined)) + .rejects.toThrow('API key name is required'); + }); + + test('should reject non-string name', async () => { + await expect(authManager.generateAPIKey(123)) + .rejects.toThrow('API key name is required'); + + await expect(authManager.generateAPIKey({})) + .rejects.toThrow('API key name is required'); + }); + }); + + describe('API Key Validation', () => { + test('should validate correct API key', async () => { + const { key, id } = await authManager.generateAPIKey('test-key'); + + // Mock credential manager to return the stored key + credentialManager.get.mockResolvedValueOnce({ + keySecret: key.split('_')[2] + }); + credentialManager.get.mockResolvedValueOnce({ + name: 'test-key', + scopes: ['read', 'write'], + createdAt: new Date().toISOString() + }); + + const validated = await authManager.validateAPIKey(key); + + expect(validated).toBeDefined(); + expect(validated.valid).toBe(true); + expect(validated.keyId).toBe(id); + expect(validated.scopes).toEqual(['read', 'write']); + }); + + test('should reject malformed API key', async () => { + const validated = await authManager.validateAPIKey('not-an-api-key'); + + expect(validated.valid).toBe(false); + }); + + test('should reject API key with wrong prefix', async () => { + const validated = await authManager.validateAPIKey('sk_abc123_def456'); + + expect(validated.valid).toBe(false); + }); + + test('should reject non-existent API key', async () => { + const fakeKey = 'dk_' + crypto.randomBytes(16).toString('hex') + '_' + crypto.randomBytes(32).toString('hex'); + credentialManager.get.mockResolvedValue(null); // Key doesn't exist + + const validated = await authManager.validateAPIKey(fakeKey); + + expect(validated.valid).toBe(false); + }); + + test('should reject revoked API key', async () => { + const { key } = await authManager.generateAPIKey('test-key'); + + credentialManager.get.mockResolvedValueOnce({ + keySecret: key.split('_')[2], + revoked: true // Key is revoked + }); + + const validated = await authManager.validateAPIKey(key); + + expect(validated.valid).toBe(false); + }); + + test('should handle null key gracefully', async () => { + const validated = await authManager.validateAPIKey(null); + + expect(validated.valid).toBe(false); + }); + + test('should handle empty key gracefully', async () => { + const validated = await authManager.validateAPIKey(''); + + expect(validated.valid).toBe(false); + }); + }); + + describe('API Key Revocation', () => { + test('should revoke API key', async () => { + const { id } = await authManager.generateAPIKey('test-key'); + + credentialManager.get.mockResolvedValue({ + keySecret: 'test-secret' + }); + + const revoked = await authManager.revokeAPIKey(id); + + expect(revoked).toBe(true); + expect(credentialManager.save).toHaveBeenCalledWith( + `auth.apikey.${id}`, + expect.objectContaining({ + revoked: true, + revokedAt: expect.any(String) + }) + ); + }); + + test('should reject revoking non-existent key', async () => { + credentialManager.get.mockResolvedValue(null); + + await expect(authManager.revokeAPIKey('nonexistent')) + .rejects.toThrow(); + }); + }); + + describe('API Key Listing', () => { + test('should list all API keys with metadata', async () => { + credentialManager.list.mockResolvedValue([ + 'auth.metadata.key1', + 'auth.metadata.key2' + ]); + + credentialManager.get.mockResolvedValueOnce({ + name: 'Key 1', + scopes: ['read'], + createdAt: '2026-01-01T00:00:00Z' + }); + + credentialManager.get.mockResolvedValueOnce({ + name: 'Key 2', + scopes: ['read', 'write'], + createdAt: '2026-01-02T00:00:00Z' + }); + + const keys = await authManager.listAPIKeys(); + + expect(keys).toHaveLength(2); + expect(keys[0].name).toBe('Key 1'); + expect(keys[1].name).toBe('Key 2'); + }); + + test('should return empty array when no keys exist', async () => { + credentialManager.list.mockResolvedValue([]); + + const keys = await authManager.listAPIKeys(); + + expect(keys).toEqual([]); + }); + }); + + describe('Security Boundaries', () => { + test('should not log sensitive token data', async () => { + const payload = { sub: 'user123' }; + const token = await authManager.generateJWT(payload); + + // Logger should never be called with the actual token + const { safeLog } = require('../logger-utils'); + const calls = safeLog.mock.calls.flat(); + expect(calls.some(arg => String(arg).includes(token))).toBe(false); + }); + + test('should not log API key secrets', async () => { + const { key } = await authManager.generateAPIKey('test-key'); + + const { safeLog } = require('../logger-utils'); + const calls = safeLog.mock.calls.flat(); + expect(calls.some(arg => String(arg).includes(key))).toBe(false); + }); + + test('should generate cryptographically secure API keys', async () => { + const key1 = await authManager.generateAPIKey('key1'); + const key2 = await authManager.generateAPIKey('key2'); + + // Keys should be unrelated (not sequential) + expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) + 1); + expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) - 1); + }); + }); +});