mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-12 06:19:11 +00:00
992 lines
40 KiB
JavaScript
992 lines
40 KiB
JavaScript
// Mock dependencies *before* importing the module that uses them
|
|
jest.mock('../configLoader');
|
|
jest.mock('axios'); // <-- Mock axios instead
|
|
|
|
// Mock axios-retry: Create a mock function for default, attach *mocked* helpers to it.
|
|
jest.mock('axios-retry', () => {
|
|
// We don't need requireActual here anymore if we mock the helpers
|
|
// const actualAxiosRetry = jest.requireActual('axios-retry');
|
|
|
|
// Create a mock function for the default export
|
|
const mockDefaultFn = jest.fn();
|
|
|
|
// Attach JEST MOCK FUNCTIONS for the helpers to the default export mock
|
|
mockDefaultFn.isNetworkError = jest.fn();
|
|
mockDefaultFn.isRetryableError = jest.fn();
|
|
mockDefaultFn.exponentialDelay = jest.fn();
|
|
|
|
// The module export
|
|
return {
|
|
__esModule: true,
|
|
default: mockDefaultFn,
|
|
// Also provide the JEST MOCK FUNCTIONS on the main module object for completeness
|
|
isNetworkError: mockDefaultFn.isNetworkError, // Point to the same mock fn
|
|
isRetryableError: mockDefaultFn.isRetryableError, // Point to the same mock fn
|
|
exponentialDelay: mockDefaultFn.exponentialDelay, // Point to the same mock fn
|
|
};
|
|
});
|
|
|
|
const { initializeApiClients, createApiClientInstance } = require('../apiClients');
|
|
const { loadConfiguration } = require('../configLoader');
|
|
const axios = require('axios'); // <-- Get the mocked axios
|
|
const axiosRetry = require('axios-retry').default; // <-- Get the mocked default export
|
|
// const proxmoxApi = require('proxmox-api'); // <-- Remove this
|
|
|
|
// Mock console to avoid cluttering test output
|
|
// jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
// jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
describe('API Clients Initialization', () => {
|
|
let originalEnv;
|
|
// Remove the shared mock instance definition from here
|
|
// const mockAxiosInstance = { ... };
|
|
|
|
beforeEach(() => {
|
|
originalEnv = { ...process.env };
|
|
jest.resetModules();
|
|
jest.clearAllMocks();
|
|
|
|
// Configure axios.create to return a *new* mock instance each time
|
|
axios.create.mockImplementation(() => ({
|
|
get: jest.fn(),
|
|
interceptors: {
|
|
request: { use: jest.fn() },
|
|
response: { use: jest.fn() } // <-- Add response interceptor mock
|
|
}
|
|
}));
|
|
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve1',
|
|
name: 'PVE Test 1',
|
|
host: '1.1.1.1',
|
|
port: '8006', // Add port for baseURL construction
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: true,
|
|
allowSelfSignedCerts: false // Add for httpsAgent
|
|
}],
|
|
pbsConfigs: [{
|
|
id: 'pbs1',
|
|
name: 'PBS Test 1',
|
|
host: '2.2.2.2',
|
|
port: '8007', // Add port for baseURL construction
|
|
username: 'root@pam',
|
|
tokenId: 'pbs-token-id',
|
|
tokenSecret: 'pbs-token-secret',
|
|
authMethod: 'token',
|
|
allowSelfSignedCerts: false // Add for httpsAgent
|
|
}],
|
|
});
|
|
|
|
});
|
|
|
|
afterEach(() => {
|
|
const currentEnvKeys = Object.keys(process.env);
|
|
currentEnvKeys.forEach(key => delete process.env[key]);
|
|
Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; });
|
|
});
|
|
|
|
test('should initialize PVE and PBS clients successfully with token auth', async () => {
|
|
// Arrange
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(loadConfiguration).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledTimes(2);
|
|
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`,
|
|
}));
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`,
|
|
}));
|
|
|
|
// Check interceptors were configured ON EACH client
|
|
// Axios.create().mock.results gives us the return values (the mock instances)
|
|
// Expect 1 call for manual auth header (axiosRetry mock doesn't add one by default)
|
|
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PVE client
|
|
expect(axios.create.mock.results[1].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PBS client
|
|
// We could also check the response interceptor use if axios-retry was mocked to verify its calls
|
|
|
|
// Check returned client structure
|
|
expect(apiClients).toHaveProperty('pve1');
|
|
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // Check it's the first mock instance
|
|
expect(apiClients.pve1.config).toEqual(endpoints[0]);
|
|
|
|
expect(pbsApiClients).toHaveProperty('pbs1');
|
|
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[1].value); // Check it's the second mock instance
|
|
expect(pbsApiClients.pbs1.config).toEqual(pbsConfigs[0]);
|
|
});
|
|
|
|
test('should handle missing PVE endpoints gracefully', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [],
|
|
pbsConfigs: [{
|
|
id: 'pbs1',
|
|
name: 'PBS Test 1',
|
|
host: '2.2.2.2',
|
|
port: '8007',
|
|
username: 'root@pam',
|
|
tokenId: 'pbs-token-id',
|
|
tokenSecret: 'pbs-token-secret',
|
|
authMethod: 'token',
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`
|
|
}));
|
|
// Check interceptor on the *single* created client
|
|
// Expect 1 call for manual auth header
|
|
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
|
|
expect(apiClients).toEqual({});
|
|
expect(pbsApiClients).toHaveProperty('pbs1');
|
|
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
|
|
});
|
|
|
|
test('should handle missing PBS endpoints gracefully', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve1',
|
|
name: 'PVE Test 1',
|
|
host: '1.1.1.1',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: true,
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`
|
|
}));
|
|
// Check interceptor on the *single* created client
|
|
// Expect 1 call for manual auth header
|
|
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
|
|
expect(pbsApiClients).toEqual({});
|
|
expect(apiClients).toHaveProperty('pve1');
|
|
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
|
|
});
|
|
|
|
test('should skip PVE endpoint if tokenId is missing', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve-no-tokenid',
|
|
name: 'PVE Missing Token ID',
|
|
host: '3.3.3.3',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
// tokenId: 'pve-token-id', // MISSING
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: true,
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on console.error
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1); // Still creates the instance initially
|
|
const createdInstance = axios.create.mock.results[0].value;
|
|
// Check that the interceptor did NOT log an error during init
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
// The client *is* created, even with missing credentials
|
|
expect(apiClients).toHaveProperty('pve-no-tokenid');
|
|
expect(apiClients['pve-no-tokenid'].client).toBe(createdInstance);
|
|
expect(pbsApiClients).toEqual({});
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
test('should skip PVE endpoint if tokenSecret is missing', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve-no-secret',
|
|
name: 'PVE Missing Secret',
|
|
host: '4.4.4.4',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
// tokenSecret: 'pve-token-secret', // MISSING
|
|
enabled: true,
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
// Check that the interceptor did NOT log an error during init
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
// The client *is* created, even with missing credentials
|
|
expect(apiClients).toHaveProperty('pve-no-secret');
|
|
expect(pbsApiClients).toEqual({});
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
test('should skip PVE endpoint if enabled is false', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve-disabled',
|
|
name: 'PVE Disabled',
|
|
host: '5.5.5.5',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: false, // DISABLED
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Spy on console.log
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).not.toHaveBeenCalled(); // Should not attempt to create client
|
|
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE Disabled (5.5.5.5)');
|
|
expect(apiClients).toEqual({});
|
|
expect(pbsApiClients).toEqual({});
|
|
|
|
consoleLogSpy.mockRestore();
|
|
});
|
|
|
|
test('should set rejectUnauthorized to false when allowSelfSignedCerts is true', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve-self-signed',
|
|
name: 'PVE Self Signed',
|
|
host: '6.6.6.6',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: true,
|
|
allowSelfSignedCerts: true // ALLOW SELF SIGNED
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
httpsAgent: expect.objectContaining({
|
|
options: expect.objectContaining({ rejectUnauthorized: false }) // Key assertion
|
|
})
|
|
}));
|
|
});
|
|
|
|
test('should set rejectUnauthorized to true when allowSelfSignedCerts is false', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [{
|
|
id: 'pve-strict-ssl',
|
|
name: 'PVE Strict SSL',
|
|
host: '7.7.7.7',
|
|
port: '8006',
|
|
username: 'root@pam',
|
|
tokenId: 'pve-token-id',
|
|
tokenSecret: 'pve-token-secret',
|
|
enabled: true,
|
|
allowSelfSignedCerts: false // STRICT SSL
|
|
}],
|
|
pbsConfigs: [],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
httpsAgent: expect.objectContaining({
|
|
options: expect.objectContaining({ rejectUnauthorized: true }) // Key assertion
|
|
})
|
|
}));
|
|
});
|
|
|
|
test('should initialize multiple PVE and PBS endpoints', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [
|
|
{ id: 'pve1', name: 'PVE 1', host: '1.1.1.1', port: '8006', username: 'root@pam', tokenId: 't1', tokenSecret: 's1', enabled: true, allowSelfSignedCerts: false },
|
|
{ id: 'pve2', name: 'PVE 2', host: '1.1.1.2', port: '8006', username: 'root@pam', tokenId: 't2', tokenSecret: 's2', enabled: true, allowSelfSignedCerts: true },
|
|
{ id: 'pve3-disabled', name: 'PVE 3', host: '1.1.1.3', port: '8006', username: 'root@pam', tokenId: 't3', tokenSecret: 's3', enabled: false, allowSelfSignedCerts: false }, // Disabled PVE
|
|
],
|
|
pbsConfigs: [
|
|
{ id: 'pbs1', name: 'PBS 1', host: '2.2.2.1', port: '8007', username: 'root@pam', tokenId: 'pbst1', tokenSecret: 'pbss1', authMethod: 'token', allowSelfSignedCerts: false },
|
|
{ id: 'pbs2', name: 'PBS 2', host: '2.2.2.2', port: '8007', username: 'root@pam', tokenId: 'pbst2', tokenSecret: 'pbss2', authMethod: 'token', allowSelfSignedCerts: true },
|
|
],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE 3 (1.1.1.3)');
|
|
expect(axios.create).toHaveBeenCalledTimes(4); // 2 enabled PVE + 2 PBS
|
|
|
|
// Check PVE clients
|
|
expect(Object.keys(apiClients)).toHaveLength(2); // Only enabled ones
|
|
expect(apiClients).toHaveProperty('pve1');
|
|
expect(apiClients).toHaveProperty('pve2');
|
|
expect(apiClients).not.toHaveProperty('pve3-disabled');
|
|
|
|
// Check specific rejectUnauthorized for PVE clients
|
|
const pve1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.1'));
|
|
const pve2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.2'));
|
|
expect(pve1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
|
|
expect(pve2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
|
|
|
|
// Check PBS clients
|
|
expect(Object.keys(pbsApiClients)).toHaveLength(2);
|
|
expect(pbsApiClients).toHaveProperty('pbs1');
|
|
expect(pbsApiClients).toHaveProperty('pbs2');
|
|
|
|
// Check specific rejectUnauthorized for PBS clients
|
|
const pbs1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.1'));
|
|
const pbs2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.2'));
|
|
expect(pbs1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
|
|
expect(pbs2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
|
|
|
|
consoleLogSpy.mockRestore();
|
|
});
|
|
|
|
test('should handle unexpected PBS authMethod', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [], // No PVE for simplicity
|
|
pbsConfigs: [{
|
|
id: 'pbs-bad-auth',
|
|
name: 'PBS Bad Auth',
|
|
host: '8.8.8.8',
|
|
port: '8007',
|
|
authMethod: 'password', // Unexpected method
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).not.toHaveBeenCalled(); // Client should not be created for this PBS
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining(`Unexpected authMethod 'password' found during PBS client initialization for: PBS Bad Auth`)
|
|
);
|
|
expect(apiClients).toEqual({});
|
|
expect(pbsApiClients).toEqual({}); // No client should be added
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
test('should handle unhandled exception during PBS client map', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [],
|
|
pbsConfigs: [{
|
|
id: 'pbs-map-error',
|
|
name: 'PBS Map Error',
|
|
host: '9.9.9.9',
|
|
port: '8007',
|
|
tokenId: 't', tokenSecret: 's', // Valid creds
|
|
authMethod: 'token',
|
|
allowSelfSignedCerts: false
|
|
}],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const mapError = new Error('Simulated map error');
|
|
// Force axios.create to throw error only for this specific host
|
|
const originalAxiosCreate = axios.create;
|
|
axios.create.mockImplementation((config) => {
|
|
if (config.baseURL.includes('9.9.9.9')) {
|
|
throw mapError;
|
|
}
|
|
// Call original mock impl for other cases (if any)
|
|
return originalAxiosCreate();
|
|
});
|
|
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Act
|
|
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(1); // Attempted to create
|
|
// Check the first argument contains the core message, allow anything for the second (stack trace)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining(`ERROR: Unhandled exception during PBS client initialization for PBS Map Error: ${mapError.message}`),
|
|
expect.anything() // Allow the stack trace as the second argument
|
|
);
|
|
expect(apiClients).toEqual({});
|
|
expect(pbsApiClients).toEqual({}); // Client not added due to error
|
|
|
|
// Restore original mock implementation if needed for other tests
|
|
axios.create.mockImplementation(originalAxiosCreate);
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
// --- Tests for Retry Logic ---
|
|
test('should call axiosRetry during initialization', async () => {
|
|
// Simple test to ensure axiosRetry is called during init
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
await initializeApiClients(endpoints, pbsConfigs);
|
|
// Expect 1 call for PVE client + 1 call for PBS client from default setup
|
|
expect(axiosRetry).toHaveBeenCalledTimes(2);
|
|
// Check args for the PVE client call
|
|
expect(axiosRetry).toHaveBeenCalledWith(
|
|
axios.create.mock.results[0].value, // The first created axios instance
|
|
expect.objectContaining({ retries: 3 }) // Check if retry config is passed
|
|
);
|
|
});
|
|
|
|
test('should log error when PVE request interceptor encounters missing credentials', async () => {
|
|
// Arrange
|
|
const missingCredsEndpoint = {
|
|
id: 'pve-bad-creds',
|
|
name: 'PVE Missing Creds',
|
|
host: '11.11.11.11',
|
|
port: '8006',
|
|
// Missing tokenId and tokenSecret
|
|
enabled: true,
|
|
allowSelfSignedCerts: false
|
|
};
|
|
loadConfiguration.mockReturnValue({ endpoints: [missingCredsEndpoint], pbsConfigs: [] });
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Mock axios.create specifically for this test
|
|
let capturedInterceptor = null; // Variable to hold the interceptor function
|
|
const mockGet = jest.fn().mockResolvedValue({ data: 'ignored' });
|
|
const mockAxiosInstance = {
|
|
get: async (url, config) => {
|
|
// Simulate running the interceptor before the request
|
|
if (capturedInterceptor) {
|
|
// Pass a mock config object, interceptor might modify it
|
|
const mockConfig = { headers: {}, url, ...config };
|
|
try {
|
|
await capturedInterceptor(mockConfig); // Run the interceptor
|
|
} catch (interceptorError) {
|
|
// If interceptor throws (e.g., Promise.reject), rethrow it
|
|
throw interceptorError;
|
|
}
|
|
}
|
|
return mockGet(url, config); // Run the actual mock get
|
|
},
|
|
interceptors: {
|
|
request: {
|
|
use: jest.fn(successFn => { // Capture the interceptor function
|
|
capturedInterceptor = successFn;
|
|
})
|
|
},
|
|
response: { use: jest.fn() }
|
|
}
|
|
};
|
|
axios.create.mockReturnValue(mockAxiosInstance);
|
|
|
|
// Act: Initialize clients (this adds the interceptor via the mock .use)
|
|
const { apiClients } = await initializeApiClients(endpoints, pbsConfigs);
|
|
const pveClient = apiClients['pve-bad-creds']?.client;
|
|
expect(pveClient).toBeDefined();
|
|
expect(capturedInterceptor).not.toBeNull(); // Check interceptor was captured
|
|
|
|
// Act: Attempt an API call which should trigger the interceptor via the mock .get
|
|
try {
|
|
await pveClient.get('/nodes');
|
|
} catch (e) {
|
|
// We don't expect the get call itself to throw here,
|
|
// the interceptor just logs an error in this case.
|
|
}
|
|
|
|
// Assert: Check that the console error was logged by the interceptor
|
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
`ERROR: Endpoint ${missingCredsEndpoint.name} is missing required API token credentials.`
|
|
);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
// Restore default axios.create mock from beforeEach
|
|
axios.create.mockImplementation(() => ({
|
|
get: jest.fn(),
|
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }
|
|
}));
|
|
});
|
|
|
|
// Removing the complex/brittle retry simulation tests below as the core logic
|
|
// is now tested via the helper function tests (pbsRetryDelayLogger, pbsRetryConditionChecker)
|
|
// and the basic call is verified by 'should call axiosRetry during initialization'.
|
|
|
|
/*
|
|
test('should retry PVE API calls on network errors', async () => {
|
|
// ... (Removed Test Code) ...
|
|
});
|
|
*/
|
|
|
|
/*
|
|
test('should retry PBS API calls on retryable errors and log warning', async () => {
|
|
// ... (Removed Test Code) ...
|
|
});
|
|
*/
|
|
|
|
// Add more tests here for:
|
|
// - Config validation errors (missing fields in loadConfiguration result)
|
|
// - Axios errors during initialization (e.g., interceptor setup fails? unlikely)
|
|
// - Multiple endpoints for PVE/PBS
|
|
// - Different auth methods (if implemented)
|
|
// - rejectUnauthorized logic
|
|
|
|
test('should correctly build baseURL for hosts with and without protocol', async () => {
|
|
// Arrange
|
|
loadConfiguration.mockReturnValue({
|
|
endpoints: [
|
|
{ id: 'pve-no-proto', name: 'PVE No Protocol', host: '1.1.1.1', port: '8006', enabled: true, tokenId: 't1', tokenSecret: 's1', allowSelfSignedCerts: false },
|
|
{ id: 'pve-with-proto', name: 'PVE With Protocol', host: 'https://1.1.1.2', port: '8006', enabled: true, tokenId: 't2', tokenSecret: 's2', allowSelfSignedCerts: false },
|
|
],
|
|
pbsConfigs: [
|
|
{ id: 'pbs-no-proto', name: 'PBS No Protocol', host: '2.2.2.1', port: '8007', authMethod: 'token', tokenId: 'pt1', tokenSecret: 'ps1', allowSelfSignedCerts: false },
|
|
{ id: 'pbs-with-proto', name: 'PBS With Protocol', host: 'https://2.2.2.2', port: '8007', authMethod: 'token', tokenId: 'pt2', tokenSecret: 'ps2', allowSelfSignedCerts: false },
|
|
],
|
|
});
|
|
const { endpoints, pbsConfigs } = loadConfiguration();
|
|
|
|
// Act
|
|
await initializeApiClients(endpoints, pbsConfigs);
|
|
|
|
// Assert
|
|
expect(axios.create).toHaveBeenCalledTimes(4); // 2 PVE + 2 PBS
|
|
|
|
// Check PVE Base URLs
|
|
const pveNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.1'));
|
|
const pveWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.2'));
|
|
expect(pveNoProtoArgs[0].baseURL).toBe('https://1.1.1.1:8006/api2/json'); // Checks the ':' branch (line 63)
|
|
expect(pveWithProtoArgs[0].baseURL).toBe('https://1.1.1.2/api2/json'); // Checks the '?' branch (line 62)
|
|
|
|
// Check PBS Base URLs
|
|
const pbsNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.1'));
|
|
const pbsWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.2'));
|
|
expect(pbsNoProtoArgs[0].baseURL).toBe('https://2.2.2.1:8007/api2/json'); // Checks the ':' branch (line 144)
|
|
expect(pbsWithProtoArgs[0].baseURL).toBe('https://2.2.2.2/api2/json'); // Checks the '?' branch (line 143)
|
|
});
|
|
|
|
});
|
|
|
|
// --- Direct Tests for Helper Functions ---
|
|
|
|
describe('API Client Helper Functions', () => {
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
// --- Tests for createApiClientInstance ---
|
|
describe('createApiClientInstance', () => {
|
|
const { createApiClientInstance } = require('../apiClients');
|
|
const axios = require('axios'); // Mocked axios
|
|
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
|
|
|
|
beforeEach(() => {
|
|
// Reset axios.create and axiosRetry mocks
|
|
axios.create.mockClear();
|
|
axiosRetry.mockClear();
|
|
// Reconfigure axios.create to return a mock instance with spied interceptors
|
|
axios.create.mockImplementation(() => ({
|
|
get: jest.fn(),
|
|
interceptors: {
|
|
request: { use: jest.fn() },
|
|
response: { use: jest.fn() }
|
|
}
|
|
}));
|
|
});
|
|
|
|
test('should create an instance with provided baseURL and httpsAgent config', () => {
|
|
const baseURL = 'https://test.com/api';
|
|
const allowSelfSignedCerts = true;
|
|
createApiClientInstance(baseURL, allowSelfSignedCerts);
|
|
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: baseURL,
|
|
httpsAgent: expect.objectContaining({
|
|
options: expect.objectContaining({ rejectUnauthorized: false })
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}));
|
|
});
|
|
|
|
test('should call request.use when authInterceptor is provided', () => {
|
|
const mockInterceptor = jest.fn();
|
|
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
|
|
|
|
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
|
|
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
|
|
});
|
|
|
|
test('should NOT call request.use when authInterceptor is NOT provided', () => {
|
|
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
|
|
|
|
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should call axiosRetry when retryConfig is provided', () => {
|
|
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
|
|
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
|
|
|
|
expect(axiosRetry).toHaveBeenCalledTimes(1);
|
|
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
|
|
retries: mockRetryConfig.retries,
|
|
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
|
|
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
|
|
});
|
|
});
|
|
|
|
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
|
|
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
|
|
|
|
expect(axiosRetry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
});
|
|
|
|
// --- createPveAuthInterceptor Tests ---
|
|
|
|
// --- createPveAuthInterceptor Tests ---
|
|
describe('createPveAuthInterceptor', () => {
|
|
const { createPveAuthInterceptor } = require('../apiClients');
|
|
const mockEndpoint = { name: 'Test PVE', tokenId: 'test-id', tokenSecret: 'test-secret' };
|
|
const mockEndpointMissingCreds = { name: 'Test PVE Bad' }; // Missing credentials
|
|
|
|
test('should return a function', () => {
|
|
const interceptor = createPveAuthInterceptor(mockEndpoint);
|
|
expect(typeof interceptor).toBe('function');
|
|
});
|
|
|
|
test('should add Authorization header if credentials exist', () => {
|
|
const interceptor = createPveAuthInterceptor(mockEndpoint);
|
|
const mockConfig = { headers: {} };
|
|
const resultConfig = interceptor(mockConfig);
|
|
expect(resultConfig.headers.Authorization).toBe(`PVEAPIToken=test-id=test-secret`);
|
|
});
|
|
|
|
test('should NOT add Authorization header and log error if credentials missing', () => {
|
|
const interceptor = createPveAuthInterceptor(mockEndpointMissingCreds);
|
|
const mockConfig = { headers: {} };
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const resultConfig = interceptor(mockConfig);
|
|
|
|
expect(resultConfig.headers.Authorization).toBeUndefined();
|
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
`ERROR: Endpoint ${mockEndpointMissingCreds.name} is missing required API token credentials.`
|
|
);
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// --- createPbsAuthInterceptor Tests ---
|
|
describe('createPbsAuthInterceptor', () => {
|
|
const { createPbsAuthInterceptor } = require('../apiClients');
|
|
const mockConfig = { tokenId: 'pbs-id', tokenSecret: 'pbs-secret' };
|
|
|
|
test('should return a function', () => {
|
|
const interceptor = createPbsAuthInterceptor(mockConfig);
|
|
expect(typeof interceptor).toBe('function');
|
|
});
|
|
|
|
test('should add correct PBS Authorization header', () => {
|
|
const interceptor = createPbsAuthInterceptor(mockConfig);
|
|
const mockReqConfig = { headers: {} };
|
|
const resultConfig = interceptor(mockReqConfig);
|
|
expect(resultConfig.headers.Authorization).toBe(`PBSAPIToken=pbs-id:pbs-secret`);
|
|
});
|
|
|
|
// Note: Add test for missing creds if validation doesn't happen before calling this
|
|
});
|
|
|
|
// --- Tests for createApiClientInstance ---
|
|
describe('createApiClientInstance', () => {
|
|
const { createApiClientInstance } = require('../apiClients');
|
|
const axios = require('axios'); // Mocked axios
|
|
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
|
|
|
|
beforeEach(() => {
|
|
// Reset axios.create and axiosRetry mocks
|
|
axios.create.mockClear();
|
|
axiosRetry.mockClear();
|
|
// Reconfigure axios.create to return a mock instance with spied interceptors
|
|
axios.create.mockImplementation(() => ({
|
|
get: jest.fn(),
|
|
interceptors: {
|
|
request: { use: jest.fn() },
|
|
response: { use: jest.fn() }
|
|
}
|
|
}));
|
|
});
|
|
|
|
test('should create an instance with provided baseURL and httpsAgent config', () => {
|
|
const baseURL = 'https://test.com/api';
|
|
const allowSelfSignedCerts = true;
|
|
createApiClientInstance(baseURL, allowSelfSignedCerts);
|
|
|
|
expect(axios.create).toHaveBeenCalledTimes(1);
|
|
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
baseURL: baseURL,
|
|
httpsAgent: expect.objectContaining({
|
|
options: expect.objectContaining({ rejectUnauthorized: false })
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}));
|
|
});
|
|
|
|
test('should call request.use when authInterceptor is provided', () => {
|
|
const mockInterceptor = jest.fn();
|
|
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
|
|
|
|
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
|
|
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
|
|
});
|
|
|
|
test('should NOT call request.use when authInterceptor is NOT provided', () => {
|
|
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
|
|
|
|
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should call axiosRetry when retryConfig is provided', () => {
|
|
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
|
|
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
|
|
|
|
expect(axiosRetry).toHaveBeenCalledTimes(1);
|
|
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
|
|
retries: mockRetryConfig.retries,
|
|
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
|
|
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
|
|
});
|
|
});
|
|
|
|
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
|
|
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
|
|
|
|
expect(axiosRetry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
});
|
|
|
|
// --- pveRetryDelayLogger Tests ---
|
|
describe('pveRetryDelayLogger', () => {
|
|
const { pveRetryDelayLogger } = require('../apiClients');
|
|
const axiosRetry = require('axios-retry').default;
|
|
|
|
beforeEach(() => {
|
|
axiosRetry.exponentialDelay.mockClear();
|
|
axiosRetry.exponentialDelay.mockReturnValue(500); // Use different value for clarity
|
|
});
|
|
|
|
test('should log warning with correct PVE details', () => {
|
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const testError = new Error('PVE Failed');
|
|
pveRetryDelayLogger('TestPVE', 3, testError);
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'Retrying PVE API request for TestPVE (attempt 3) due to error: PVE Failed'
|
|
);
|
|
consoleWarnSpy.mockRestore();
|
|
});
|
|
|
|
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
|
|
const result = pveRetryDelayLogger('TestPVE', 2, new Error('Test'));
|
|
|
|
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
|
|
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(2); // Called with retryCount
|
|
expect(result).toBe(500); // Returns the mock value
|
|
});
|
|
});
|
|
|
|
// --- pbsRetryDelayLogger Tests ---
|
|
describe('pbsRetryDelayLogger', () => {
|
|
const { pbsRetryDelayLogger } = require('../apiClients');
|
|
// Get the mocked default export which has the mocked helpers
|
|
const axiosRetry = require('axios-retry').default;
|
|
|
|
beforeEach(() => {
|
|
// Reset mocks before each test in this suite
|
|
axiosRetry.exponentialDelay.mockClear();
|
|
axiosRetry.exponentialDelay.mockReturnValue(1000); // Set default mock return for simplicity
|
|
});
|
|
|
|
test('should log warning with correct details', () => {
|
|
// ... (this test remains the same, just checking console.warn) ...
|
|
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const testError = new Error('PBS Failed');
|
|
pbsRetryDelayLogger('TestPBS', 2, testError);
|
|
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'Retrying PBS API request for TestPBS (Token Auth - attempt 2) due to error: PBS Failed'
|
|
);
|
|
consoleWarnSpy.mockRestore();
|
|
});
|
|
|
|
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
|
|
// No spy needed, just call the function and check the pre-existing mock
|
|
const result = pbsRetryDelayLogger('TestPBS', 1, new Error('Test'));
|
|
|
|
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
|
|
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(1);
|
|
expect(result).toBe(1000); // Should return the mock value
|
|
});
|
|
});
|
|
|
|
// --- pbsRetryConditionChecker Tests ---
|
|
describe('pbsRetryConditionChecker', () => {
|
|
const { pbsRetryConditionChecker } = require('../apiClients');
|
|
// Get the mocked default export which has the mocked helpers
|
|
const axiosRetry = require('axios-retry').default;
|
|
|
|
beforeEach(() => {
|
|
// Reset mocks and set default return values before each test
|
|
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
|
|
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
|
|
});
|
|
|
|
// No afterEach needed as we clear in beforeEach
|
|
|
|
test('should return true for network errors', () => {
|
|
const networkError = new Error('Network Error');
|
|
axiosRetry.isNetworkError.mockReturnValue(true); // Override default mock return
|
|
axiosRetry.isRetryableError.mockReturnValue(false); // Ensure this stays false for the test
|
|
|
|
expect(pbsRetryConditionChecker(networkError)).toBe(true);
|
|
// Verify mocks were called (or not called due to short-circuit)
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
|
|
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Corrected assertion
|
|
});
|
|
|
|
test('should return true for retryable errors', () => {
|
|
const retryableError = new Error('Retryable Error');
|
|
retryableError.response = { status: 503 };
|
|
axiosRetry.isRetryableError.mockReturnValue(true); // Override default mock return
|
|
|
|
expect(pbsRetryConditionChecker(retryableError)).toBe(true);
|
|
// Verify mocks were called
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
|
|
});
|
|
|
|
test('should return false for non-network, non-retryable errors', () => {
|
|
const otherError = new Error('Other Error');
|
|
// Default mock returns (false, false) are already set in beforeEach
|
|
|
|
expect(pbsRetryConditionChecker(otherError)).toBe(false);
|
|
// Verify mocks were called
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
|
|
});
|
|
});
|
|
|
|
// --- pveRetryConditionChecker Tests ---
|
|
describe('pveRetryConditionChecker', () => {
|
|
const { pveRetryConditionChecker } = require('../apiClients');
|
|
const axiosRetry = require('axios-retry').default;
|
|
|
|
beforeEach(() => {
|
|
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
|
|
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
|
|
});
|
|
|
|
test('should return true for network errors', () => {
|
|
const networkError = new Error('Network Error');
|
|
axiosRetry.isNetworkError.mockReturnValue(true);
|
|
expect(pveRetryConditionChecker(networkError)).toBe(true);
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
|
|
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Short-circuits
|
|
});
|
|
|
|
test('should return true for retryable errors', () => {
|
|
const retryableError = new Error('Retryable Error');
|
|
axiosRetry.isRetryableError.mockReturnValue(true);
|
|
expect(pveRetryConditionChecker(retryableError)).toBe(true);
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
|
|
});
|
|
|
|
test('should return true for error with status 596', () => {
|
|
const status596Error = new Error('Status 596 Error');
|
|
status596Error.response = { status: 596 };
|
|
// Ensure other checks are false
|
|
axiosRetry.isNetworkError.mockReturnValue(false);
|
|
axiosRetry.isRetryableError.mockReturnValue(false);
|
|
|
|
expect(pveRetryConditionChecker(status596Error)).toBe(true);
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status596Error);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status596Error);
|
|
});
|
|
|
|
test('should return false for other errors without status 596', () => {
|
|
const otherError = new Error('Other Error');
|
|
// Ensure other checks are false (default from beforeEach)
|
|
expect(pveRetryConditionChecker(otherError)).toBe(false);
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
|
|
});
|
|
|
|
test('should return false for error with different response status', () => {
|
|
const status500Error = new Error('Status 500 Error');
|
|
status500Error.response = { status: 500 };
|
|
// Ensure other checks are false (default from beforeEach)
|
|
expect(pveRetryConditionChecker(status500Error)).toBe(false);
|
|
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status500Error);
|
|
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status500Error);
|
|
});
|
|
});
|
|
|
|
});
|