Files
dashcaddy/dashcaddy-api/__tests__/auth-manager.test.js

368 lines
12 KiB
JavaScript

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