import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { VaultClient, VaultError } from '../src/connectors/vault'; import type { VaultSecretResponse } from '../src/types'; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; describe('VaultClient', () => { const baseUrl = 'https://vault.anvil.it.com'; const token = 'hvs.test-token'; let client: VaultClient; beforeEach(() => { client = new VaultClient(baseUrl, token); mockFetch.mockClear(); }); afterEach(() => { vi.clearAllTimers(); }); describe('constructor', () => { it('should initialize with correct baseUrl and token', () => { expect(client).toBeInstanceOf(VaultClient); }); it('should remove trailing slash from baseUrl', () => { const clientWithSlash = new VaultClient('https://vault.anvil.it.com/', token); expect(clientWithSlash).toBeInstanceOf(VaultClient); }); }); describe('getCredentials', () => { const mockSuccessResponse: VaultSecretResponse = { data: { data: { provider: 'linode', api_token: 'test-api-token-123', }, metadata: { created_time: '2024-01-21T10:00:00Z', custom_metadata: null, deletion_time: '', destroyed: false, version: 1, }, }, }; it('should successfully retrieve credentials from Vault', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockSuccessResponse, }); const credentials = await client.getCredentials('linode'); expect(credentials).toEqual({ provider: 'linode', api_token: 'test-api-token-123', }); expect(mockFetch).toHaveBeenCalledWith( 'https://vault.anvil.it.com/v1/secret/data/linode', expect.objectContaining({ method: 'GET', headers: { 'X-Vault-Token': token, 'Content-Type': 'application/json', }, }) ); }); it('should use cached credentials on second call', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockSuccessResponse, }); // First call - should fetch from Vault const credentials1 = await client.getCredentials('linode'); expect(mockFetch).toHaveBeenCalledTimes(1); // Second call - should use cache const credentials2 = await client.getCredentials('linode'); expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch expect(credentials2).toEqual(credentials1); }); it('should throw VaultError on 401 Unauthorized', async () => { mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ errors: ['permission denied'] }), }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should throw VaultError on 403 Forbidden', async () => { mockFetch.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({ errors: ['permission denied'] }), }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should throw VaultError on 404 Not Found', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', json: async () => ({}), }); await expect(client.getCredentials('unknown')).rejects.toThrow(VaultError); }); it('should throw VaultError on 500 Server Error', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({ errors: ['internal server error'] }), }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should throw VaultError on timeout', async () => { // Mock fetch to simulate AbortError mockFetch.mockImplementation(() => { const error = new Error('This operation was aborted'); error.name = 'AbortError'; return Promise.reject(error); }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should throw VaultError on invalid response structure', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ invalid: 'structure' }), }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should throw VaultError on missing required fields', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ data: { data: { provider: 'linode', api_token: '', // Empty token }, metadata: mockSuccessResponse.data.metadata, }, }), }); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); it('should handle network errors gracefully', async () => { mockFetch.mockRejectedValue(new Error('Network error')); await expect(client.getCredentials('linode')).rejects.toThrow(VaultError); }); }); describe('cache management', () => { it('should clear cache for specific provider', async () => { const mockResponse: VaultSecretResponse = { data: { data: { provider: 'linode', api_token: 'token1' }, metadata: { created_time: '2024-01-21T10:00:00Z', custom_metadata: null, deletion_time: '', destroyed: false, version: 1, }, }, }; mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }); // Fetch and cache await client.getCredentials('linode'); expect(mockFetch).toHaveBeenCalledTimes(1); // Clear cache client.clearCache('linode'); // Should fetch again await client.getCredentials('linode'); expect(mockFetch).toHaveBeenCalledTimes(2); }); it('should clear all cache', async () => { const mockLinode: VaultSecretResponse = { data: { data: { provider: 'linode', api_token: 'token1' }, metadata: { created_time: '2024-01-21T10:00:00Z', custom_metadata: null, deletion_time: '', destroyed: false, version: 1, }, }, }; const mockVultr: VaultSecretResponse = { data: { data: { provider: 'vultr', api_token: 'token2' }, metadata: { created_time: '2024-01-21T10:00:00Z', custom_metadata: null, deletion_time: '', destroyed: false, version: 1, }, }, }; mockFetch .mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockLinode, }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockVultr, }); // Cache both providers await client.getCredentials('linode'); await client.getCredentials('vultr'); const statsBefore = client.getCacheStats(); expect(statsBefore.size).toBe(2); expect(statsBefore.providers).toContain('linode'); expect(statsBefore.providers).toContain('vultr'); // Clear all cache client.clearCache(); const statsAfter = client.getCacheStats(); expect(statsAfter.size).toBe(0); expect(statsAfter.providers).toEqual([]); }); it('should provide cache statistics', async () => { const mockResponse: VaultSecretResponse = { data: { data: { provider: 'linode', api_token: 'token1' }, metadata: { created_time: '2024-01-21T10:00:00Z', custom_metadata: null, deletion_time: '', destroyed: false, version: 1, }, }, }; mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }); // Initially empty let stats = client.getCacheStats(); expect(stats.size).toBe(0); expect(stats.providers).toEqual([]); // After caching await client.getCredentials('linode'); stats = client.getCacheStats(); expect(stats.size).toBe(1); expect(stats.providers).toEqual(['linode']); }); }); describe('VaultError', () => { it('should create error with all properties', () => { const error = new VaultError('Test error', 404, 'linode'); expect(error).toBeInstanceOf(Error); expect(error.name).toBe('VaultError'); expect(error.message).toBe('Test error'); expect(error.statusCode).toBe(404); expect(error.provider).toBe('linode'); }); it('should create error without optional properties', () => { const error = new VaultError('Test error'); expect(error.message).toBe('Test error'); expect(error.statusCode).toBeUndefined(); expect(error.provider).toBeUndefined(); }); }); });