0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
1552 lines
50 KiB
TypeScript
1552 lines
50 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
|
|
import { cloudConfig } from '../../src/globalConfig/cloud';
|
|
import * as cloudModule from '../../src/util/cloud';
|
|
import {
|
|
ConfigPermissionError,
|
|
checkCloudPermissions,
|
|
getConfigFromCloud,
|
|
getDefaultTeam,
|
|
getEvalConfigFromCloud,
|
|
getPluginSeverityOverridesFromCloud,
|
|
getPoliciesFromCloud,
|
|
getProviderFromCloud,
|
|
makeRequest,
|
|
validateLinkedTargetId,
|
|
} from '../../src/util/cloud';
|
|
import { fetchWithProxy } from '../../src/util/fetch/index';
|
|
import { checkServerFeatureSupport } from '../../src/util/server';
|
|
|
|
import type { UnifiedConfig } from '../../src/types';
|
|
|
|
vi.mock('../../src/util/fetch/index.ts');
|
|
vi.mock('../../src/globalConfig/cloud');
|
|
vi.mock('../../src/util/server');
|
|
vi.mock('../../src/util/cloud', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('../../src/util/cloud')>('../../src/util/cloud');
|
|
return {
|
|
...actual,
|
|
cloudCanBuildFormattedConfig: vi.fn().mockResolvedValue(true),
|
|
};
|
|
});
|
|
|
|
describe('cloud utils', () => {
|
|
const mockFetchWithProxy = vi.mocked(fetchWithProxy);
|
|
const mockCloudConfig = vi.mocked(cloudConfig);
|
|
const mockCheckServerFeatureSupport = vi.mocked(checkServerFeatureSupport);
|
|
let mockMakeRequest: MockInstance;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
mockCloudConfig.getApiHost.mockReturnValue('https://api.example.com');
|
|
mockCloudConfig.getApiKey.mockReturnValue('test-api-key');
|
|
|
|
mockMakeRequest = vi.spyOn(cloudModule, 'makeRequest');
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockMakeRequest?.mockRestore();
|
|
});
|
|
|
|
describe('makeRequest', () => {
|
|
it('should make request with correct URL and headers', async () => {
|
|
const path = 'test/path';
|
|
const method = 'POST';
|
|
const body = { data: 'test' };
|
|
|
|
await makeRequest(path, method, body);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should make GET request without body', async () => {
|
|
const path = 'test/path';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle undefined API key', async () => {
|
|
mockCloudConfig.getApiKey.mockReturnValue(undefined);
|
|
|
|
const path = 'test/path';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer undefined', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle empty path', async () => {
|
|
const path = '';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/', {
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle API host without trailing slash', async () => {
|
|
mockCloudConfig.getApiHost.mockReturnValue('https://api.example.com');
|
|
|
|
const path = 'test/path';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/test/path',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should handle API host with trailing slash', async () => {
|
|
mockCloudConfig.getApiHost.mockReturnValue('https://api.example.com');
|
|
|
|
const path = 'test/path';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/test/path',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should handle path with leading slash', async () => {
|
|
const path = '/test/path';
|
|
const method = 'GET';
|
|
|
|
await makeRequest(path, method);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle complex request body', async () => {
|
|
const path = 'test/path';
|
|
const method = 'POST';
|
|
const body = {
|
|
string: 'test',
|
|
number: 123,
|
|
boolean: true,
|
|
array: [1, 2, 3],
|
|
nested: {
|
|
field: 'value',
|
|
},
|
|
};
|
|
|
|
await makeRequest(path, method, body);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle non-JSON body', async () => {
|
|
const path = 'test/path';
|
|
const method = 'POST';
|
|
const body = 'plain text body';
|
|
|
|
await makeRequest(path, method, body);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
|
|
it('should handle null/undefined body', async () => {
|
|
const path = 'test/path';
|
|
const method = 'POST';
|
|
|
|
await makeRequest(path, method, null);
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'POST',
|
|
body: 'null',
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
await makeRequest(path, method, undefined);
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.example.com/api/v1/test/path', {
|
|
method: 'POST',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getProviderFromCloud', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should fetch and parse provider successfully', async () => {
|
|
const mockProvider = {
|
|
config: {
|
|
id: 'test-provider',
|
|
label: 'Test Provider',
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockProvider),
|
|
ok: true,
|
|
} as Response);
|
|
|
|
const result = await getProviderFromCloud('test-provider');
|
|
|
|
expect(result).toEqual({ ...mockProvider.config });
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/providers/test-provider',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should throw error when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(getProviderFromCloud('test-provider')).rejects.toThrow(
|
|
'Could not fetch Provider test-provider from cloud. Cloud config is not enabled.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when provider fetch fails', async () => {
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(getProviderFromCloud('test-provider')).rejects.toThrow(
|
|
'Failed to fetch provider from cloud: test-provider.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when provider has no id', async () => {
|
|
const mockProvider = {
|
|
config: {
|
|
label: 'Test Provider',
|
|
// Missing id field
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({ buildDate: '2025-03-011' }),
|
|
} as Response);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockProvider),
|
|
} as Response);
|
|
|
|
await expect(getProviderFromCloud('test-provider')).rejects.toThrow(
|
|
'Failed to fetch provider from cloud: test-provider.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getConfigFromCloud', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should fetch unified config when formatted config is supported', async () => {
|
|
const mockUnifiedConfig = {
|
|
description: 'Test Config',
|
|
providers: ['test-provider'],
|
|
prompts: ['test prompt'],
|
|
tests: [{ vars: { input: 'test' } }],
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockUnifiedConfig),
|
|
ok: true,
|
|
} as Response);
|
|
|
|
const result = await getConfigFromCloud('test-config');
|
|
|
|
expect(result).toEqual(mockUnifiedConfig);
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/redteam/configs/test-config/unified',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should fetch unified config with target', async () => {
|
|
const mockUnifiedConfig = {
|
|
description: 'Test Config',
|
|
providers: ['test-provider'],
|
|
prompts: ['test prompt'],
|
|
tests: [{ vars: { input: 'test' } }],
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockUnifiedConfig),
|
|
ok: true,
|
|
} as Response);
|
|
|
|
const result = await getConfigFromCloud('test-config', 'test-provider');
|
|
|
|
expect(result).toEqual(mockUnifiedConfig);
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/redteam/configs/test-config/unified?providerId=test-provider',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should throw error when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(getConfigFromCloud('test-config')).rejects.toThrow(
|
|
'Could not fetch Config test-config from cloud. Cloud config is not enabled.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when config fetch fails', async () => {
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(getConfigFromCloud('test-config')).rejects.toThrow(
|
|
'Failed to fetch config from cloud: test-config.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when response is not ok', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
statusText: 'Not Found',
|
|
json: () => Promise.resolve({}),
|
|
} as Response);
|
|
|
|
await expect(getConfigFromCloud('test-config')).rejects.toThrow(
|
|
'Failed to fetch config from cloud: test-config.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getEvalConfigFromCloud', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should fetch and normalize eval config from cloud envelope', async () => {
|
|
const providerId = '12345678-1234-4234-8234-123456789abc';
|
|
const responseBody = {
|
|
config: {
|
|
id: 'config-id',
|
|
name: 'My Cloud Eval Config',
|
|
config: {
|
|
providerIds: [providerId],
|
|
prompts: [{ id: 'prompt-1', content: 'Hello {{name}}' }],
|
|
testCases: [{ vars: { name: 'World' } }],
|
|
delay: 250,
|
|
maxConcurrency: 2,
|
|
verbose: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(responseBody),
|
|
} as Response);
|
|
|
|
const result = await getEvalConfigFromCloud('eval-config-id');
|
|
|
|
expect(result.description).toBe('My Cloud Eval Config');
|
|
expect(result.providers).toEqual([`promptfoo://provider/${providerId}`]);
|
|
expect(result.prompts).toEqual(['Hello {{name}}']);
|
|
expect(result.tests).toEqual([{ vars: { name: 'World' } }]);
|
|
expect(result.commandLineOptions).toEqual({
|
|
delay: 250,
|
|
maxConcurrency: 2,
|
|
verbose: true,
|
|
});
|
|
expect((result as any).providerIds).toBeUndefined();
|
|
expect((result as any).testCases).toBeUndefined();
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/configs/eval-config-id',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should default tests to an empty array when absent', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
config: {
|
|
description: 'Cloud Eval',
|
|
providers: ['promptfoo://provider/12345678-1234-4234-8234-123456789abc'],
|
|
prompts: ['Say hello'],
|
|
},
|
|
}),
|
|
} as Response);
|
|
|
|
const result = await getEvalConfigFromCloud('eval-config-id');
|
|
expect(result.tests).toEqual([]);
|
|
});
|
|
|
|
it('should throw error when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(getEvalConfigFromCloud('eval-config-id')).rejects.toThrow(
|
|
'Could not fetch Config eval-config-id from cloud. Cloud config is not enabled.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when eval config fetch fails', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve('Config not found'),
|
|
} as Response);
|
|
|
|
await expect(getEvalConfigFromCloud('eval-config-id')).rejects.toThrow(
|
|
'Failed to fetch config from cloud: Not Found',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPluginSeverityOverridesFromCloud', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should fetch and parse plugin severity overrides successfully', async () => {
|
|
const mockProvider = { pluginSeverityOverrideId: 'override-1' };
|
|
const mockOverride = {
|
|
id: 'override-1',
|
|
members: [
|
|
{ pluginId: 'plugin1', severity: 'HIGH' },
|
|
{ pluginId: 'plugin2', severity: 'LOW' },
|
|
],
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProvider),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockOverride),
|
|
} as any);
|
|
|
|
const result = await getPluginSeverityOverridesFromCloud('test-provider');
|
|
|
|
expect(result).toEqual({
|
|
id: 'override-1',
|
|
severities: {
|
|
plugin1: 'HIGH',
|
|
plugin2: 'LOW',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should return null when no override ID exists', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({}),
|
|
} as any);
|
|
|
|
const result = await getPluginSeverityOverridesFromCloud('test-provider');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should throw error when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('test-provider')).rejects.toThrow(
|
|
'Could not fetch plugin severity overrides from cloud. Cloud config is not enabled.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when provider fetch fails', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve('Provider not found'),
|
|
} as any);
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('test-provider')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when override fetch fails', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ pluginSeverityOverrideId: 'override-1' }),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve('Override not found'),
|
|
} as any);
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('test-provider')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should handle empty members array', async () => {
|
|
const mockProvider = { pluginSeverityOverrideId: 'override-1' };
|
|
const mockOverride = { id: 'override-1', members: [] };
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProvider),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockOverride),
|
|
} as any);
|
|
|
|
const result = await getPluginSeverityOverridesFromCloud('test-provider');
|
|
|
|
expect(result).toEqual({
|
|
id: 'override-1',
|
|
severities: {},
|
|
});
|
|
});
|
|
|
|
it('should fetch and parse plugin severity overrides with one member', async () => {
|
|
const mockProvider = { pluginSeverityOverrideId: 'override-2' };
|
|
const mockOverride = {
|
|
id: 'override-2',
|
|
members: [{ pluginId: 'pluginX', severity: 'MEDIUM' }],
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProvider),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockOverride),
|
|
} as any);
|
|
|
|
const result = await getPluginSeverityOverridesFromCloud('provider-2');
|
|
expect(result).toEqual({
|
|
id: 'override-2',
|
|
severities: {
|
|
pluginX: 'MEDIUM',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle override response with unexpected member fields', async () => {
|
|
const mockProvider = { pluginSeverityOverrideId: 'override-3' };
|
|
const mockOverride = {
|
|
id: 'override-3',
|
|
members: [
|
|
{ pluginId: 'pluginX', severity: 'LOW', extra: 'field' },
|
|
{ pluginId: 'pluginY', severity: 'HIGH' },
|
|
],
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockProvider),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockOverride),
|
|
} as any);
|
|
|
|
const result = await getPluginSeverityOverridesFromCloud('provider-3');
|
|
expect(result).toEqual({
|
|
id: 'override-3',
|
|
severities: {
|
|
pluginX: 'LOW',
|
|
pluginY: 'HIGH',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should throw error if first fetch throws synchronously', async () => {
|
|
mockFetchWithProxy.mockImplementationOnce(() => {
|
|
throw new Error('Synchronous fetch error');
|
|
});
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('provider-err')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error if second fetch throws synchronously', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ pluginSeverityOverrideId: 'override-err' }),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockImplementationOnce(() => {
|
|
throw new Error('Synchronous fetch error 2');
|
|
});
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('provider-err2')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error if first fetch rejects', async () => {
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Async fetch error'));
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('provider-err3')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error if second fetch rejects', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ pluginSeverityOverrideId: 'override-err4' }),
|
|
} as any);
|
|
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Async fetch error 2'));
|
|
|
|
await expect(getPluginSeverityOverridesFromCloud('provider-err4')).rejects.toThrow(
|
|
'Failed to fetch plugin severity overrides from cloud.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getPoliciesFromCloud', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should fetch and parse policies successfully with name attribute', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'policy-1',
|
|
text: 'Policy text 1',
|
|
name: 'Policy Name 1',
|
|
severity: 'high',
|
|
},
|
|
{
|
|
id: 'policy-2',
|
|
text: 'Policy text 2',
|
|
name: 'Policy Name 2',
|
|
severity: 'medium',
|
|
},
|
|
{
|
|
id: 'policy-3',
|
|
text: 'Policy text 3',
|
|
name: 'Policy Name 3',
|
|
severity: 'low',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['policy-1', 'policy-2', 'policy-3'], 'team-123');
|
|
|
|
expect(result).toBeInstanceOf(Map);
|
|
expect(result.size).toBe(3);
|
|
|
|
expect(result.get('policy-1')).toEqual({
|
|
text: 'Policy text 1',
|
|
name: 'Policy Name 1',
|
|
severity: 'high',
|
|
});
|
|
|
|
expect(result.get('policy-2')).toEqual({
|
|
text: 'Policy text 2',
|
|
name: 'Policy Name 2',
|
|
severity: 'medium',
|
|
});
|
|
|
|
expect(result.get('policy-3')).toEqual({
|
|
text: 'Policy text 3',
|
|
name: 'Policy Name 3',
|
|
severity: 'low',
|
|
});
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/custom-policies/?id=policy-1&id=policy-2&id=policy-3&teamId=team-123',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle single policy with name attribute', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'single-policy',
|
|
text: 'Single policy text',
|
|
name: 'Single Policy Name',
|
|
severity: 'critical',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['single-policy'], 'team-456');
|
|
|
|
expect(result).toBeInstanceOf(Map);
|
|
expect(result.size).toBe(1);
|
|
expect(result.get('single-policy')).toEqual({
|
|
text: 'Single policy text',
|
|
name: 'Single Policy Name',
|
|
severity: 'critical',
|
|
});
|
|
});
|
|
|
|
it('should handle policies with long names', async () => {
|
|
const longName =
|
|
'This is a very long policy name that contains many words and should be handled properly by the system';
|
|
const mockPolicies = [
|
|
{
|
|
id: 'long-name-policy',
|
|
text: 'Policy with long name',
|
|
name: longName,
|
|
severity: 'high',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['long-name-policy'], 'team-789');
|
|
|
|
expect(result.get('long-name-policy')).toEqual({
|
|
text: 'Policy with long name',
|
|
name: longName,
|
|
severity: 'high',
|
|
});
|
|
});
|
|
|
|
it('should handle policies with special characters in names', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'special-chars-policy',
|
|
text: 'Policy text',
|
|
name: 'Policy with special chars: @#$%^&*()_+[]{}|;\':",.<>?/',
|
|
severity: 'medium',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['special-chars-policy'], 'team-special');
|
|
|
|
expect(result.get('special-chars-policy')).toEqual({
|
|
text: 'Policy text',
|
|
name: 'Policy with special chars: @#$%^&*()_+[]{}|;\':",.<>?/',
|
|
severity: 'medium',
|
|
});
|
|
});
|
|
|
|
it('should handle empty policy list', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve([]),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud([], 'team-empty');
|
|
|
|
expect(result).toBeInstanceOf(Map);
|
|
expect(result.size).toBe(0);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/custom-policies/?&teamId=team-empty',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should throw error when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(getPoliciesFromCloud(['policy-1'], 'team-123')).rejects.toThrow(
|
|
'Could not fetch policies from cloud. Cloud config is not enabled. Please run `promptfoo auth login` to login.',
|
|
);
|
|
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw error when response is not ok', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve('Policies not found'),
|
|
} as Response);
|
|
|
|
await expect(getPoliciesFromCloud(['policy-1'], 'team-123')).rejects.toThrow(
|
|
'Failed to fetch policies from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when fetch rejects', async () => {
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(getPoliciesFromCloud(['policy-1'], 'team-123')).rejects.toThrow(
|
|
'Failed to fetch policies from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should throw error when response has invalid JSON', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.reject(new Error('Invalid JSON')),
|
|
} as Response);
|
|
|
|
await expect(getPoliciesFromCloud(['policy-1'], 'team-123')).rejects.toThrow(
|
|
'Failed to fetch policies from cloud.',
|
|
);
|
|
});
|
|
|
|
it('should handle multiple policies with different severities and names', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'critical-policy',
|
|
text: 'Critical policy text',
|
|
name: 'Critical Security Policy',
|
|
severity: 'critical',
|
|
},
|
|
{
|
|
id: 'high-policy',
|
|
text: 'High policy text',
|
|
name: 'High Priority Policy',
|
|
severity: 'high',
|
|
},
|
|
{
|
|
id: 'medium-policy',
|
|
text: 'Medium policy text',
|
|
name: 'Medium Risk Policy',
|
|
severity: 'medium',
|
|
},
|
|
{
|
|
id: 'low-policy',
|
|
text: 'Low policy text',
|
|
name: 'Low Impact Policy',
|
|
severity: 'low',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(
|
|
['critical-policy', 'high-policy', 'medium-policy', 'low-policy'],
|
|
'team-all-severities',
|
|
);
|
|
|
|
expect(result.size).toBe(4);
|
|
|
|
expect(result.get('critical-policy')).toEqual({
|
|
text: 'Critical policy text',
|
|
name: 'Critical Security Policy',
|
|
severity: 'critical',
|
|
});
|
|
|
|
expect(result.get('high-policy')).toEqual({
|
|
text: 'High policy text',
|
|
name: 'High Priority Policy',
|
|
severity: 'high',
|
|
});
|
|
|
|
expect(result.get('medium-policy')).toEqual({
|
|
text: 'Medium policy text',
|
|
name: 'Medium Risk Policy',
|
|
severity: 'medium',
|
|
});
|
|
|
|
expect(result.get('low-policy')).toEqual({
|
|
text: 'Low policy text',
|
|
name: 'Low Impact Policy',
|
|
severity: 'low',
|
|
});
|
|
});
|
|
|
|
it('should properly encode multiple policy IDs in URL', async () => {
|
|
const policyIds = ['policy-1', 'policy-2', 'policy-3', 'policy-4', 'policy-5'];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve([]),
|
|
} as Response);
|
|
|
|
await getPoliciesFromCloud(policyIds, 'team-multi');
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/custom-policies/?id=policy-1&id=policy-2&id=policy-3&id=policy-4&id=policy-5&teamId=team-multi',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle policies with empty names', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'empty-name-policy',
|
|
text: 'Policy with empty name',
|
|
name: '',
|
|
severity: 'high',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['empty-name-policy'], 'team-empty-name');
|
|
|
|
expect(result.get('empty-name-policy')).toEqual({
|
|
text: 'Policy with empty name',
|
|
name: '',
|
|
severity: 'high',
|
|
});
|
|
});
|
|
|
|
it('should handle policies with unicode characters in names', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'unicode-policy',
|
|
text: 'Unicode policy text',
|
|
name: 'Policy with unicode: 你好世界 🌍 مرحبا العالم',
|
|
severity: 'medium',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['unicode-policy'], 'team-unicode');
|
|
|
|
expect(result.get('unicode-policy')).toEqual({
|
|
text: 'Unicode policy text',
|
|
name: 'Policy with unicode: 你好世界 🌍 مرحبا العالم',
|
|
severity: 'medium',
|
|
});
|
|
});
|
|
|
|
it('should handle response with missing fields gracefully', async () => {
|
|
const mockPolicies = [
|
|
{
|
|
id: 'incomplete-policy',
|
|
text: 'Incomplete policy text',
|
|
// name field might be missing in some edge cases
|
|
severity: 'high',
|
|
},
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockPolicies),
|
|
} as Response);
|
|
|
|
const result = await getPoliciesFromCloud(['incomplete-policy'], 'team-incomplete');
|
|
|
|
// The function should still work even if name is undefined
|
|
expect(result.get('incomplete-policy')).toEqual({
|
|
text: 'Incomplete policy text',
|
|
name: undefined,
|
|
severity: 'high',
|
|
});
|
|
});
|
|
|
|
it('should handle team IDs with special characters', async () => {
|
|
const specialTeamId = 'team-123-@#$%';
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve([]),
|
|
} as Response);
|
|
|
|
await getPoliciesFromCloud(['policy-1'], specialTeamId);
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
`https://api.example.com/api/v1/custom-policies/?id=policy-1&teamId=${specialTeamId}`,
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle HTTP error with detailed message', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
text: () => Promise.resolve('Database connection failed'),
|
|
} as Response);
|
|
|
|
await expect(getPoliciesFromCloud(['policy-1'], 'team-error')).rejects.toThrow(
|
|
'Failed to fetch policies from cloud.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getDefaultTeam', () => {
|
|
it('should return the oldest team', async () => {
|
|
const mockTeams = [
|
|
{ id: 'team-3', name: 'Team 3', createdAt: '2023-01-03T00:00:00Z' },
|
|
{ id: 'team-1', name: 'Team 1', createdAt: '2023-01-01T00:00:00Z' },
|
|
{ id: 'team-2', name: 'Team 2', createdAt: '2023-01-02T00:00:00Z' },
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTeams),
|
|
} as Response);
|
|
|
|
const result = await getDefaultTeam();
|
|
|
|
expect(result).toEqual({ id: 'team-1', name: 'Team 1', createdAt: '2023-01-01T00:00:00Z' });
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/users/me/teams',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle single team', async () => {
|
|
const mockTeams = [
|
|
{ id: 'team-single', name: 'Single Team', createdAt: '2023-01-01T00:00:00Z' },
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTeams),
|
|
} as Response);
|
|
|
|
const result = await getDefaultTeam();
|
|
|
|
expect(result).toEqual({
|
|
id: 'team-single',
|
|
name: 'Single Team',
|
|
createdAt: '2023-01-01T00:00:00Z',
|
|
});
|
|
});
|
|
|
|
it('should handle teams with same creation date', async () => {
|
|
const mockTeams = [
|
|
{ id: 'team-a', name: 'Team A', createdAt: '2023-01-01T00:00:00Z' },
|
|
{ id: 'team-b', name: 'Team B', createdAt: '2023-01-01T00:00:00Z' },
|
|
];
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockTeams),
|
|
} as Response);
|
|
|
|
const result = await getDefaultTeam();
|
|
|
|
// Should return the first one when dates are the same
|
|
expect(result).toEqual({ id: 'team-a', name: 'Team A', createdAt: '2023-01-01T00:00:00Z' });
|
|
});
|
|
|
|
it('should throw error when request fails', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
statusText: 'Unauthorized',
|
|
} as Response);
|
|
|
|
await expect(getDefaultTeam()).rejects.toThrow('Failed to get user teams: Unauthorized');
|
|
});
|
|
|
|
it('should throw error when fetch throws', async () => {
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await expect(getDefaultTeam()).rejects.toThrow('Network error');
|
|
});
|
|
|
|
it('should handle empty teams array', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve([]),
|
|
} as Response);
|
|
|
|
await expect(getDefaultTeam()).rejects.toThrow('No teams found for user');
|
|
});
|
|
});
|
|
|
|
describe('checkCloudPermissions', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
mockCheckServerFeatureSupport.mockResolvedValue(true);
|
|
});
|
|
|
|
it('should return early when cloud config is not enabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mockCheckServerFeatureSupport).not.toHaveBeenCalled();
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return early with warning when no providers specified', async () => {
|
|
await expect(checkCloudPermissions({})).resolves.toBeUndefined();
|
|
|
|
expect(mockCheckServerFeatureSupport).not.toHaveBeenCalled();
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return early when server feature is not supported', async () => {
|
|
mockCheckServerFeatureSupport.mockResolvedValue(false);
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mockCheckServerFeatureSupport).toHaveBeenCalledWith(
|
|
'config-permission-check-endpoint',
|
|
'2025-09-03T14:49:11Z',
|
|
);
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass when permissions check succeeds', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true }),
|
|
} as Response);
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/permissions/check',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ config: { providers: ['test-provider'] } }),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should throw ConfigPermissionError when response is 403', async () => {
|
|
const errorData = {
|
|
errors: [
|
|
{ type: 'permission', id: 'access_denied', message: 'Access denied' },
|
|
{
|
|
type: 'permission',
|
|
id: 'insufficient_permissions',
|
|
message: 'Insufficient permissions',
|
|
},
|
|
],
|
|
};
|
|
mockFetchWithProxy.mockResolvedValue({
|
|
ok: false,
|
|
status: 403,
|
|
json: () => Promise.resolve(errorData),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
ConfigPermissionError,
|
|
);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
'Permission denied: permission access_denied: Access denied, permission insufficient_permissions: Insufficient permissions',
|
|
);
|
|
});
|
|
|
|
it('should throw ConfigPermissionError when response is 403 with single error', async () => {
|
|
const errorData = { error: 'Single error message' };
|
|
mockFetchWithProxy.mockResolvedValue({
|
|
ok: false,
|
|
status: 403,
|
|
json: () => Promise.resolve(errorData),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
ConfigPermissionError,
|
|
);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
'Permission denied: config unknown: Single error message',
|
|
);
|
|
});
|
|
|
|
it('should throw ConfigPermissionError when response is 403 with malformed JSON', async () => {
|
|
mockFetchWithProxy.mockResolvedValue({
|
|
ok: false,
|
|
status: 403,
|
|
json: () => Promise.reject(new Error('Invalid JSON')),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
ConfigPermissionError,
|
|
);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
'Permission denied: config unknown: Unknown error',
|
|
);
|
|
});
|
|
|
|
it('should log warning and continue for non-403 errors', async () => {
|
|
const errorData = {
|
|
errors: [{ type: 'server', id: 'internal_error', message: 'Server error' }],
|
|
};
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.resolve(errorData),
|
|
} as Response);
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should throw ConfigPermissionError when result contains errors', async () => {
|
|
const resultWithErrors = {
|
|
errors: [
|
|
{ type: 'config', id: 'validation_failed', message: 'Config validation failed' },
|
|
{ type: 'config', id: 'invalid_provider', message: 'Invalid provider' },
|
|
],
|
|
};
|
|
mockFetchWithProxy.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(resultWithErrors),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
ConfigPermissionError,
|
|
);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
'Not able to continue with config: config validation_failed: Config validation failed, config invalid_provider: Invalid provider',
|
|
);
|
|
});
|
|
|
|
it('should pass when result contains empty errors array', async () => {
|
|
const resultWithEmptyErrors = { errors: [] };
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(resultWithEmptyErrors),
|
|
} as Response);
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should log warning and continue when server feature check throws', async () => {
|
|
mockCheckServerFeatureSupport.mockRejectedValue(new Error('Server check failed'));
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should log warning and continue when fetch throws non-ConfigPermissionError', async () => {
|
|
mockFetchWithProxy.mockRejectedValue(new Error('Network error'));
|
|
|
|
await expect(
|
|
checkCloudPermissions({ providers: ['test-provider'] }),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should re-throw ConfigPermissionError when makeRequest throws ConfigPermissionError', async () => {
|
|
const configError = new ConfigPermissionError('Permission denied');
|
|
mockFetchWithProxy.mockRejectedValue(configError);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
ConfigPermissionError,
|
|
);
|
|
|
|
await expect(checkCloudPermissions({ providers: ['test-provider'] })).rejects.toThrow(
|
|
'Permission denied',
|
|
);
|
|
});
|
|
|
|
it('should handle complex config object', async () => {
|
|
const complexConfig = {
|
|
providers: ['provider1', 'provider2'],
|
|
prompts: ['prompt1'],
|
|
tests: [{ vars: { input: 'test' } }],
|
|
redteam: { plugins: [{ id: 'plugin1' }] },
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true }),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions(complexConfig)).resolves.toBeUndefined();
|
|
|
|
// Should strip tests and replace redteam with empty object
|
|
const expectedConfig = {
|
|
providers: ['provider1', 'provider2'],
|
|
prompts: ['prompt1'],
|
|
redteam: {},
|
|
};
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/permissions/check',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ config: expectedConfig }),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should strip heavy fields from config before sending', async () => {
|
|
const largeConfig: Partial<UnifiedConfig> = {
|
|
providers: ['provider1'],
|
|
prompts: ['prompt1'],
|
|
metadata: { configId: 'config-123', teamId: 'team-456' },
|
|
tests: Array.from({ length: 300 }, (_, i) => ({
|
|
vars: { input: `test-${i}` },
|
|
assert: [{ type: 'contains' as const, value: `expected-${i}` }],
|
|
})),
|
|
scenarios: [
|
|
{
|
|
config: [{ vars: { scenario: 'test' } }],
|
|
tests: [{ vars: { input: 'scenario-test' } }],
|
|
},
|
|
],
|
|
defaultTest: { vars: { default: 'value' } },
|
|
evaluateOptions: { maxConcurrency: 5 },
|
|
redteam: {
|
|
plugins: [{ id: 'plugin1' as const }, { id: 'plugin2' as const }],
|
|
strategies: [{ id: 'strategy1' as const }],
|
|
purpose: 'A very long purpose string',
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true }),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions(largeConfig)).resolves.toBeUndefined();
|
|
|
|
const sentBody = JSON.parse((mockFetchWithProxy.mock.calls[0] as any[])[1].body as string);
|
|
const sentConfig = sentBody.config;
|
|
|
|
// Should keep providers, prompts, and metadata
|
|
expect(sentConfig.providers).toEqual(['provider1']);
|
|
expect(sentConfig.prompts).toEqual(['prompt1']);
|
|
expect(sentConfig.metadata).toEqual({ configId: 'config-123', teamId: 'team-456' });
|
|
|
|
// Should strip heavy fields
|
|
expect(sentConfig.tests).toBeUndefined();
|
|
expect(sentConfig.scenarios).toBeUndefined();
|
|
expect(sentConfig.defaultTest).toBeUndefined();
|
|
expect(sentConfig.evaluateOptions).toBeUndefined();
|
|
|
|
// Should replace redteam with empty object (preserving truthiness)
|
|
expect(sentConfig.redteam).toEqual({});
|
|
});
|
|
|
|
it('should handle config with undefined providers', async () => {
|
|
await expect(checkCloudPermissions({ providers: undefined })).resolves.toBeUndefined();
|
|
|
|
expect(mockCheckServerFeatureSupport).not.toHaveBeenCalled();
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle config with empty providers array', async () => {
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true }),
|
|
} as Response);
|
|
|
|
await expect(checkCloudPermissions({ providers: [] })).resolves.toBeUndefined();
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/permissions/check',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ config: { providers: [] } }),
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('validateLinkedTargetId', () => {
|
|
beforeEach(() => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(true);
|
|
});
|
|
|
|
it('should accept valid linkedTargetId when cloud is enabled', async () => {
|
|
const validLinkedTargetId = 'promptfoo://provider/12345678-1234-1234-1234-123456789abc';
|
|
const mockProvider = {
|
|
config: {
|
|
id: 'test-provider',
|
|
label: 'Test Provider',
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockProvider),
|
|
ok: true,
|
|
} as Response);
|
|
|
|
await expect(validateLinkedTargetId(validLinkedTargetId)).resolves.toBeUndefined();
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/providers/12345678-1234-1234-1234-123456789abc',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should warn but not fail when cloud is disabled', async () => {
|
|
mockCloudConfig.isEnabled.mockReturnValue(false);
|
|
const validLinkedTargetId = 'promptfoo://provider/12345678-1234-1234-1234-123456789abc';
|
|
|
|
await expect(validateLinkedTargetId(validLinkedTargetId)).resolves.toBeUndefined();
|
|
|
|
// Should not make API call when cloud is disabled
|
|
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw error for invalid prefix', async () => {
|
|
const invalidPrefix = 'wrong://provider/12345678-1234-1234-1234-123456789abc';
|
|
|
|
const promise = validateLinkedTargetId(invalidPrefix);
|
|
|
|
await expect(promise).rejects.toThrow('Invalid linkedTargetId format');
|
|
await expect(promise).rejects.toThrow(invalidPrefix);
|
|
await expect(promise).rejects.toThrow('promptfoo://provider/');
|
|
await expect(promise).rejects.toThrow('To get a valid linkedTargetId:');
|
|
});
|
|
|
|
it('should throw error when target does not exist in cloud', async () => {
|
|
const validLinkedTargetId = 'promptfoo://provider/12345678-1234-1234-1234-123456789abc';
|
|
|
|
mockFetchWithProxy.mockRejectedValueOnce(new Error('Not found'));
|
|
|
|
const promise = validateLinkedTargetId(validLinkedTargetId);
|
|
|
|
await expect(promise).rejects.toThrow('linkedTargetId not found');
|
|
await expect(promise).rejects.toThrow(validLinkedTargetId);
|
|
await expect(promise).rejects.toThrow('Troubleshooting steps');
|
|
await expect(promise).rejects.toThrow('promptfoo auth status');
|
|
});
|
|
|
|
it('should throw error when API returns non-ok response', async () => {
|
|
const validLinkedTargetId = 'promptfoo://provider/12345678-1234-1234-1234-123456789abc';
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
} as Response);
|
|
|
|
const promise = validateLinkedTargetId(validLinkedTargetId);
|
|
|
|
await expect(promise).rejects.toThrow('linkedTargetId not found');
|
|
await expect(promise).rejects.toThrow(validLinkedTargetId);
|
|
await expect(promise).rejects.toThrow('Troubleshooting steps');
|
|
});
|
|
|
|
it('should accept any ID format after valid prefix (defers to cloud)', async () => {
|
|
// Cloud API will validate the actual format - we just check prefix
|
|
const linkedTargetId = 'promptfoo://provider/any-id-format-here';
|
|
const mockProvider = {
|
|
config: {
|
|
id: 'test-provider',
|
|
},
|
|
};
|
|
|
|
mockFetchWithProxy.mockResolvedValueOnce({
|
|
json: () => Promise.resolve(mockProvider),
|
|
ok: true,
|
|
} as Response);
|
|
|
|
await expect(validateLinkedTargetId(linkedTargetId)).resolves.toBeUndefined();
|
|
|
|
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/v1/providers/any-id-format-here',
|
|
{
|
|
method: 'GET',
|
|
body: undefined,
|
|
headers: { Authorization: 'Bearer test-api-key', 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|