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
761 lines
26 KiB
TypeScript
761 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import cliState from '../../src/cliState';
|
|
import { getEnvString } from '../../src/envars';
|
|
import { CLOUD_API_HOST, CloudConfig, cloudConfig } from '../../src/globalConfig/cloud';
|
|
import { readGlobalConfig, writeGlobalConfigPartial } from '../../src/globalConfig/globalConfig';
|
|
import logger from '../../src/logger';
|
|
import { fetchWithProxy } from '../../src/util/fetch/index';
|
|
import { mockProcessEnv } from '../util/utils';
|
|
|
|
vi.mock('../../src/util/fetch/index');
|
|
vi.mock('../../src/logger');
|
|
vi.mock('../../src/globalConfig/globalConfig');
|
|
|
|
describe('CloudConfig', () => {
|
|
let cloudConfigInstance: CloudConfig;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: {
|
|
appUrl: 'https://test.app',
|
|
apiHost: 'https://test.api',
|
|
apiKey: 'test-key',
|
|
},
|
|
});
|
|
cloudConfigInstance = new CloudConfig();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should support deferred singleton-style initialization', () => {
|
|
vi.mocked(readGlobalConfig).mockClear();
|
|
const config = new CloudConfig(false);
|
|
|
|
expect(readGlobalConfig).not.toHaveBeenCalled();
|
|
expect(config.getAppUrl()).toBe('https://test.app');
|
|
expect(readGlobalConfig).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should initialize with default values when no saved config exists', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
const config = new CloudConfig();
|
|
expect(config.getAppUrl()).toBe('https://www.promptfoo.app');
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
expect(config.getApiKey()).toBeUndefined();
|
|
});
|
|
|
|
it('should initialize with saved config values', () => {
|
|
expect(cloudConfigInstance.getAppUrl()).toBe('https://test.app');
|
|
expect(cloudConfigInstance.getApiHost()).toBe('https://test.api');
|
|
expect(cloudConfigInstance.getApiKey()).toBe('test-key');
|
|
});
|
|
|
|
it('should ignore the legacy API_HOST environment variable and warn once when cloud is enabled', () => {
|
|
// Env files routinely define API_HOST for the app under test; the cloud
|
|
// origin decides where monkeyPatchFetch sends the saved bearer token, so
|
|
// a generic variable must never redirect it.
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiKey: 'saved-key' },
|
|
});
|
|
const restoreEnv = mockProcessEnv({
|
|
API_HOST: 'https://env-file.example.com',
|
|
PROMPTFOO_CLOUD_API_URL: undefined,
|
|
});
|
|
|
|
try {
|
|
const config = new CloudConfig(false);
|
|
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Ignoring the API_HOST environment variable'),
|
|
);
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should not warn about API_HOST when no cloud credential is configured', () => {
|
|
// monkeyPatchFetch resolves the host on every request; evals that never
|
|
// touch Cloud must not see a Cloud warning just because their env file
|
|
// configures API_HOST for the app under test.
|
|
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
|
const restoreEnv = mockProcessEnv({
|
|
API_HOST: 'https://env-file.example.com',
|
|
PROMPTFOO_CLOUD_API_URL: undefined,
|
|
PROMPTFOO_API_KEY: undefined,
|
|
});
|
|
|
|
try {
|
|
const config = new CloudConfig(false);
|
|
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should prefer PROMPTFOO_CLOUD_API_URL without warning about API_HOST', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
|
const restoreEnv = mockProcessEnv({
|
|
API_HOST: 'https://env-file.example.com',
|
|
PROMPTFOO_CLOUD_API_URL: 'https://self-hosted.example.com',
|
|
});
|
|
|
|
try {
|
|
const config = new CloudConfig(false);
|
|
|
|
expect(config.getApiHost()).toBe('https://self-hosted.example.com');
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
|
|
it('should ignore API_HOST from config-level env overrides', () => {
|
|
// The cloud origin decides where monkeyPatchFetch sends the saved bearer
|
|
// token, so an eval config's `env` block must never be able to set it.
|
|
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
|
const restoreEnv = mockProcessEnv({
|
|
API_HOST: undefined,
|
|
PROMPTFOO_CLOUD_API_URL: undefined,
|
|
});
|
|
const originalConfig = cliState.config;
|
|
cliState.config = { env: { API_HOST: 'https://attacker.example.com' } };
|
|
|
|
try {
|
|
const config = new CloudConfig(false);
|
|
|
|
expect(getEnvString('API_HOST')).toBe('https://attacker.example.com');
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
} finally {
|
|
cliState.config = originalConfig;
|
|
restoreEnv();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isEnabled', () => {
|
|
it('should return true when apiKey exists', () => {
|
|
expect(cloudConfigInstance.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should return false when apiKey does not exist', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
const config = new CloudConfig();
|
|
expect(config.isEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('setters and getters', () => {
|
|
it('should set and get apiHost', () => {
|
|
cloudConfigInstance.setApiHost('https://new.api');
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
|
cloud: expect.objectContaining({
|
|
apiHost: 'https://new.api',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('should strip a trailing slash when persisting apiHost', () => {
|
|
cloudConfigInstance.setApiHost('https://onprem.example.com/');
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
|
cloud: expect.objectContaining({
|
|
apiHost: 'https://onprem.example.com',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('should set and get apiKey', () => {
|
|
cloudConfigInstance.setApiKey('new-key');
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
|
cloud: expect.objectContaining({
|
|
apiKey: 'new-key',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('should set and get appUrl', () => {
|
|
cloudConfigInstance.setAppUrl('https://new.app');
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
|
cloud: expect.objectContaining({
|
|
appUrl: 'https://new.app',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('should set and get sharing', () => {
|
|
cloudConfigInstance.setSharing(true);
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
|
cloud: expect.objectContaining({
|
|
sharing: true,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('should return undefined for sharing when not set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: {},
|
|
});
|
|
const config = new CloudConfig();
|
|
expect(config.getSharing()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should clear cloud config', () => {
|
|
cloudConfigInstance.delete();
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({ cloud: {} });
|
|
});
|
|
|
|
it('should reset in-memory state after delete', () => {
|
|
// After delete, readGlobalConfig returns empty cloud config
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: {},
|
|
});
|
|
cloudConfigInstance.delete();
|
|
expect(cloudConfigInstance.getSharing()).toBeUndefined();
|
|
expect(cloudConfigInstance.getApiKey()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('validateAndSetApiToken', () => {
|
|
const mockResponse = {
|
|
user: {
|
|
id: '1',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
organization: {
|
|
id: '1',
|
|
name: 'Test Org',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
app: {
|
|
url: 'https://test.app',
|
|
},
|
|
hasActiveLicense: true,
|
|
};
|
|
|
|
it('should validate token and update config on success', async () => {
|
|
const mockFetchResponse = {
|
|
ok: true,
|
|
json: () => Promise.resolve(mockResponse),
|
|
text: () => Promise.resolve(JSON.stringify(mockResponse)),
|
|
} as Response;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(mockFetchResponse);
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken(
|
|
'test-token',
|
|
'https://test.api',
|
|
);
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
// Verify sharing was persisted as true
|
|
expect(writeGlobalConfigPartial).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cloud: expect.objectContaining({
|
|
sharing: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should set sharing to false when hasActiveLicense is false and user created after cutoff (public cloud)', async () => {
|
|
const noLicenseResponse = {
|
|
...mockResponse,
|
|
hasActiveLicense: false,
|
|
user: { ...mockResponse.user, createdAt: new Date('2026-03-10T00:00:00Z') },
|
|
};
|
|
const mockFetchResponse = {
|
|
ok: true,
|
|
json: () => Promise.resolve(noLicenseResponse),
|
|
text: () => Promise.resolve(JSON.stringify(noLicenseResponse)),
|
|
} as Response;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(mockFetchResponse);
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('test-token', CLOUD_API_HOST);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({
|
|
cloud: expect.objectContaining({
|
|
sharing: false,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should set sharing to true when hasActiveLicense is false but user created before cutoff (public cloud, grandfathered)', async () => {
|
|
const grandfatheredResponse = {
|
|
...mockResponse,
|
|
hasActiveLicense: false,
|
|
user: { ...mockResponse.user, createdAt: new Date('2026-03-01T00:00:00Z') },
|
|
};
|
|
const mockFetchResponse = {
|
|
ok: true,
|
|
json: () => Promise.resolve(grandfatheredResponse),
|
|
text: () => Promise.resolve(JSON.stringify(grandfatheredResponse)),
|
|
} as Response;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(mockFetchResponse);
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('test-token', CLOUD_API_HOST);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({
|
|
cloud: expect.objectContaining({
|
|
sharing: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should preserve existing sharing value when public cloud omits hasActiveLicense', async () => {
|
|
// Pre-set sharing to true to verify it is preserved
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: {
|
|
appUrl: 'https://test.app',
|
|
apiHost: 'https://test.api',
|
|
apiKey: 'test-key',
|
|
sharing: true,
|
|
},
|
|
});
|
|
cloudConfigInstance = new CloudConfig();
|
|
|
|
const { hasActiveLicense: _, ...noLicenseResponse } = mockResponse;
|
|
const mockFetchResponse = {
|
|
ok: true,
|
|
json: () => Promise.resolve(noLicenseResponse),
|
|
text: () => Promise.resolve(JSON.stringify(noLicenseResponse)),
|
|
} as Response;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(mockFetchResponse);
|
|
const setSharingSpy = vi.spyOn(cloudConfigInstance, 'setSharing');
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('test-token', CLOUD_API_HOST);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
// setSharing should NOT have been called when hasActiveLicense is undefined
|
|
expect(setSharingSpy).not.toHaveBeenCalled();
|
|
// The existing sharing value should be preserved
|
|
expect(cloudConfigInstance.getSharing()).toBe(true);
|
|
});
|
|
|
|
it('should throw error on failed validation', async () => {
|
|
const mockErrorResponse = {
|
|
ok: false,
|
|
statusText: 'Unauthorized',
|
|
json: () => Promise.reject(new Error('Unauthorized')),
|
|
text: () => Promise.resolve('Unauthorized'),
|
|
} as Response;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(mockErrorResponse);
|
|
|
|
await expect(
|
|
cloudConfigInstance.validateAndSetApiToken('invalid-token', 'https://test.api'),
|
|
).rejects.toThrow('Failed to validate API token: Unauthorized');
|
|
});
|
|
});
|
|
|
|
describe('on-prem sharing behavior', () => {
|
|
const onPremHost = 'https://promptfoo.example.com';
|
|
const onPremResponse = {
|
|
user: {
|
|
id: '1',
|
|
name: 'On-Prem User',
|
|
email: 'user@example.com',
|
|
// Account created well after the public-cloud grandfathering cutoff.
|
|
createdAt: new Date('2026-04-01T00:00:00Z'),
|
|
updatedAt: new Date('2026-04-01T00:00:00Z'),
|
|
},
|
|
organization: {
|
|
id: '1',
|
|
name: 'On-Prem Org',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
app: { url: 'https://promptfoo.example.com' },
|
|
};
|
|
|
|
function makeFetch(body: object) {
|
|
return {
|
|
ok: true,
|
|
json: () => Promise.resolve(body),
|
|
text: () => Promise.resolve(JSON.stringify(body)),
|
|
} as Response;
|
|
}
|
|
|
|
it('should enable sharing when on-prem server returns hasActiveLicense: false and user created after cutoff', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
makeFetch({ ...onPremResponse, hasActiveLicense: false }),
|
|
);
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('token', onPremHost);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it('should enable sharing when on-prem server returns hasActiveLicense: false and user created before cutoff', async () => {
|
|
const body = {
|
|
...onPremResponse,
|
|
hasActiveLicense: false,
|
|
user: { ...onPremResponse.user, createdAt: new Date('2026-03-01T00:00:00Z') },
|
|
};
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(makeFetch(body));
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('token', onPremHost);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it('should enable sharing when on-prem server returns hasActiveLicense: true', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
makeFetch({ ...onPremResponse, hasActiveLicense: true }),
|
|
);
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', onPremHost);
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it('should enable sharing for on-prem host with trailing slash', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
makeFetch({ ...onPremResponse, hasActiveLicense: false }),
|
|
);
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', `${onPremHost}/`);
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it('should enable sharing when an on-prem server omits hasActiveLicense', async () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: {
|
|
appUrl: 'https://promptfoo.example.com',
|
|
apiHost: onPremHost,
|
|
apiKey: 'existing-key',
|
|
sharing: false,
|
|
},
|
|
});
|
|
cloudConfigInstance = new CloudConfig();
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(makeFetch(onPremResponse));
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', onPremHost);
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
'https://api.promptfoo.app.internal.example.com',
|
|
'https://onprem.example.com/prefix/api.promptfoo.app',
|
|
'https://api.promptfoo.app@onprem.example.com',
|
|
'https://www.promptfoo.app.internal.example.com',
|
|
'https://www.promptfoo.app@onprem.example.com',
|
|
'https://promptfoo.app.internal.example.com',
|
|
'https://promptfoo.app@onprem.example.com',
|
|
])('should enable sharing for on-prem URL %s', async (host) => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
makeFetch({ ...onPremResponse, hasActiveLicense: false }),
|
|
);
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', host);
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: true }) }),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
'https://api.promptfoo.app',
|
|
'https://www.promptfoo.app',
|
|
'https://promptfoo.app',
|
|
])('should apply the license gate behind an API proxy for app URL %s', async (appUrl) => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
makeFetch({
|
|
...onPremResponse,
|
|
app: { url: appUrl },
|
|
hasActiveLicense: false,
|
|
}),
|
|
);
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', 'https://cloud-proxy.example.com');
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: false }) }),
|
|
);
|
|
});
|
|
|
|
it('should disable sharing for public cloud host with hasActiveLicense: false after cutoff', async () => {
|
|
const body = {
|
|
...onPremResponse,
|
|
hasActiveLicense: false,
|
|
user: { ...onPremResponse.user, createdAt: new Date('2026-04-01T00:00:00Z') },
|
|
};
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(makeFetch(body));
|
|
|
|
const result = await cloudConfigInstance.validateAndSetApiToken('token', CLOUD_API_HOST);
|
|
|
|
expect(result.hasActiveLicense).toBe(false);
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: false }) }),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
CLOUD_API_HOST.toUpperCase(),
|
|
`${CLOUD_API_HOST}.`,
|
|
`${CLOUD_API_HOST}/prefix`,
|
|
'https://www.promptfoo.app',
|
|
'https://WWW.PROMPTFOO.APP.',
|
|
'https://www.promptfoo.app/prefix',
|
|
'https://promptfoo.app',
|
|
'https://PROMPTFOO.APP.',
|
|
'https://promptfoo.app/prefix',
|
|
])('should apply the license gate to public cloud URL %s', async (host) => {
|
|
const body = {
|
|
...onPremResponse,
|
|
hasActiveLicense: false,
|
|
user: { ...onPremResponse.user, createdAt: new Date('2026-04-01T00:00:00Z') },
|
|
};
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(makeFetch(body));
|
|
|
|
await cloudConfigInstance.validateAndSetApiToken('token', host);
|
|
|
|
const lastCall = vi.mocked(writeGlobalConfigPartial).mock.calls.at(-1)?.[0];
|
|
expect(lastCall).toEqual(
|
|
expect.objectContaining({ cloud: expect.objectContaining({ sharing: false }) }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('cloudConfig singleton', () => {
|
|
it('should be an instance of CloudConfig', () => {
|
|
expect(cloudConfig).toBeInstanceOf(CloudConfig);
|
|
});
|
|
});
|
|
|
|
describe('getApiKey with environment variable', () => {
|
|
const originalEnv = process.env.PROMPTFOO_API_KEY;
|
|
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) {
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
} else {
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: originalEnv });
|
|
}
|
|
});
|
|
|
|
it('should return API key from config file when set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiKey: 'config-key' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiKey()).toBe('config-key');
|
|
});
|
|
|
|
it('should return API key from PROMPTFOO_API_KEY env var when config is empty', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: 'env-key' });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiKey()).toBe('env-key');
|
|
});
|
|
|
|
it('should prefer config file over env var when both are set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiKey: 'config-key' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: 'env-key' });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiKey()).toBe('config-key');
|
|
});
|
|
|
|
it('should return undefined when neither config nor env var is set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiKey()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('isEnabled with environment variable', () => {
|
|
const originalEnv = process.env.PROMPTFOO_API_KEY;
|
|
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) {
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
} else {
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: originalEnv });
|
|
}
|
|
});
|
|
|
|
it('should return true when config file has apiKey', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiKey: 'config-key' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should return true when PROMPTFOO_API_KEY env var is set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: 'env-key' });
|
|
const config = new CloudConfig();
|
|
expect(config.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should return false when neither config nor env var is set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_API_KEY: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.isEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getApiHost with environment variable', () => {
|
|
const originalEnv = process.env.PROMPTFOO_CLOUD_API_URL;
|
|
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) {
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined });
|
|
} else {
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: originalEnv });
|
|
}
|
|
});
|
|
|
|
it('should return API host from config file when set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiHost: 'https://config-host.example.com' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://config-host.example.com');
|
|
});
|
|
|
|
it('should return API host from PROMPTFOO_CLOUD_API_URL env var when config is empty', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: 'https://env-host.example.com' });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://env-host.example.com');
|
|
});
|
|
|
|
it('should prefer config file over env var when both are set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiHost: 'https://config-host.example.com' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: 'https://env-host.example.com' });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://config-host.example.com');
|
|
});
|
|
|
|
it('should return the default cloud host when neither config nor env var is set', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined, API_HOST: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe(CLOUD_API_HOST);
|
|
});
|
|
|
|
it('should strip a trailing slash from a config-file host', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiHost: 'https://onprem.example.com/' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://onprem.example.com');
|
|
});
|
|
|
|
it('should strip multiple trailing slashes from a config-file host', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiHost: 'https://onprem.example.com///' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://onprem.example.com');
|
|
});
|
|
|
|
it('should strip a trailing slash from the PROMPTFOO_CLOUD_API_URL env var', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: 'https://env-host.example.com/' });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://env-host.example.com');
|
|
});
|
|
|
|
it('should preserve a path segment while stripping only the trailing slash', () => {
|
|
vi.mocked(readGlobalConfig).mockReturnValue({
|
|
id: 'test-id',
|
|
cloud: { apiHost: 'https://onprem.example.com/prefix/' },
|
|
});
|
|
mockProcessEnv({ PROMPTFOO_CLOUD_API_URL: undefined });
|
|
const config = new CloudConfig();
|
|
expect(config.getApiHost()).toBe('https://onprem.example.com/prefix');
|
|
});
|
|
});
|
|
});
|