chore: import upstream snapshot with attribution
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,959 @@
|
||||
import input from '@inquirer/input';
|
||||
import chalk from 'chalk';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnvString, isCI } from '../../src/envars';
|
||||
import {
|
||||
checkEmailStatus,
|
||||
checkEmailStatusAndMaybeExit,
|
||||
clearUserEmail,
|
||||
EmailValidationError,
|
||||
getAuthMethod,
|
||||
getAuthor,
|
||||
getUserAuthInfo,
|
||||
getUserEmail,
|
||||
getUserId,
|
||||
isLoggedIntoCloud,
|
||||
promptForEmailUnverified,
|
||||
setUserEmail,
|
||||
} from '../../src/globalConfig/accounts';
|
||||
import { cloudConfig } from '../../src/globalConfig/cloud';
|
||||
import {
|
||||
readGlobalConfig,
|
||||
writeGlobalConfig,
|
||||
writeGlobalConfigPartial,
|
||||
} from '../../src/globalConfig/globalConfig';
|
||||
import logger from '../../src/logger';
|
||||
import telemetry from '../../src/telemetry';
|
||||
import { fetchWithTimeout } from '../../src/util/fetch/index';
|
||||
|
||||
// Mock fetchWithTimeout before any imports that might use telemetry
|
||||
vi.mock('../../src/util/fetch/index', () => ({
|
||||
fetchWithTimeout: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@inquirer/input');
|
||||
vi.mock('../../src/envars');
|
||||
vi.mock('../../src/telemetry', () => {
|
||||
const mockTelemetry = {
|
||||
record: vi.fn().mockResolvedValue(undefined),
|
||||
identify: vi.fn(),
|
||||
saveConsent: vi.fn().mockResolvedValue(undefined),
|
||||
disabled: false,
|
||||
};
|
||||
return {
|
||||
default: mockTelemetry,
|
||||
Telemetry: vi.fn().mockImplementation(() => mockTelemetry),
|
||||
};
|
||||
});
|
||||
vi.mock('../../src/util/fetch/index.ts');
|
||||
vi.mock('../../src/globalConfig/globalConfig');
|
||||
vi.mock('../../src/util');
|
||||
vi.mock('../../src/logger');
|
||||
|
||||
describe('accounts', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('PROMPTFOO_API_KEY', undefined);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should return existing ID from global config', () => {
|
||||
const existingId = 'existing-test-id';
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: existingId,
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const result = getUserId();
|
||||
|
||||
expect(result).toBe(existingId);
|
||||
expect(writeGlobalConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate new ID and save to config when no ID exists', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const result = getUserId();
|
||||
|
||||
// Should return a UUID-like string
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
// Should have saved the config with the new ID
|
||||
expect(writeGlobalConfig).toHaveBeenCalledWith({
|
||||
account: { email: 'test@example.com' },
|
||||
id: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate new ID when global config is null', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue(null as any);
|
||||
|
||||
const result = getUserId();
|
||||
|
||||
// Should return a UUID-like string
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
// Should have saved the config with the new ID
|
||||
expect(writeGlobalConfig).toHaveBeenCalledWith({
|
||||
id: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate new ID when global config is undefined', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue(undefined as any);
|
||||
|
||||
const result = getUserId();
|
||||
|
||||
// Should return a UUID-like string
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
// Should have saved the config with the new ID
|
||||
expect(writeGlobalConfig).toHaveBeenCalledWith({
|
||||
id: result,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate new ID when config exists but has no id property', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const result = getUserId();
|
||||
|
||||
// Should return a UUID-like string
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
|
||||
// Should have saved the config with the new ID
|
||||
expect(writeGlobalConfig).toHaveBeenCalledWith({
|
||||
account: { email: 'test@example.com' },
|
||||
id: result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEmail', () => {
|
||||
it('should return email from global config', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
expect(getUserEmail()).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should return null if no email in config', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
});
|
||||
expect(getUserEmail()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAuthInfo', () => {
|
||||
it('should return the account and cloud auth snapshot from one config read', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
|
||||
expect(getUserAuthInfo()).toEqual({
|
||||
email: 'test@example.com',
|
||||
isLoggedIntoCloud: true,
|
||||
authMethod: 'api-key',
|
||||
});
|
||||
expect(readGlobalConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use the environment API key when cloud config has no API key', () => {
|
||||
vi.stubEnv('PROMPTFOO_API_KEY', 'env-api-key');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: {},
|
||||
});
|
||||
|
||||
expect(getUserAuthInfo()).toEqual({
|
||||
email: 'test@example.com',
|
||||
isLoggedIntoCloud: true,
|
||||
authMethod: 'api-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report email auth when only email is configured', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
expect(getUserAuthInfo()).toEqual({
|
||||
email: 'test@example.com',
|
||||
isLoggedIntoCloud: false,
|
||||
authMethod: 'email',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report no auth when no email or API key is configured', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
});
|
||||
|
||||
expect(getUserAuthInfo()).toEqual({
|
||||
email: null,
|
||||
isLoggedIntoCloud: false,
|
||||
authMethod: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUserEmail', () => {
|
||||
it('should write email to global config', () => {
|
||||
// Must mock readGlobalConfig to ensure clean state (no leftover account properties from other tests)
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
});
|
||||
const email = 'test@example.com';
|
||||
setUserEmail(email);
|
||||
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
||||
account: { email },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearUserEmail', () => {
|
||||
it('should remove email from global config when email exists', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
clearUserEmail();
|
||||
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
||||
account: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clearing when no account exists', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
});
|
||||
clearUserEmail();
|
||||
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
||||
account: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clearing when global config is empty', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({});
|
||||
clearUserEmail();
|
||||
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
||||
account: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthor', () => {
|
||||
it('should use override when not logged into cloud', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
||||
expect(getAuthor('override@example.com')).toBe('override@example.com');
|
||||
});
|
||||
|
||||
it('should fall back to user email when no override and not logged into cloud', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
expect(getAuthor()).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should fall back to env var when no override and no user email', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('author@env.com');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
||||
expect(getAuthor()).toBe('author@env.com');
|
||||
});
|
||||
|
||||
it('should return null if no author found', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({ id: 'test-id' });
|
||||
expect(getAuthor()).toBeNull();
|
||||
});
|
||||
|
||||
it('should prefer cloud identity over override when logged into cloud', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'cloud@example.com' },
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthor('override@example.com')).toBe('cloud@example.com');
|
||||
});
|
||||
|
||||
it('should prefer cloud identity over env var when logged into cloud', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('author@env.com');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'cloud@example.com' },
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthor()).toBe('cloud@example.com');
|
||||
});
|
||||
|
||||
it('should fall back to override when logged into cloud but no stored email', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthor('override@example.com')).toBe('override@example.com');
|
||||
});
|
||||
|
||||
it('should fall back to env var when logged into cloud but no stored email', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('author@env.com');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthor()).toBe('author@env.com');
|
||||
});
|
||||
|
||||
it('should return null when logged into cloud, no stored email, no override, no env', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthor()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForEmailUnverified', () => {
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use CI email if in CI environment', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
await promptForEmailUnverified();
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not prompt for email if already set', async () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'existing@example.com' },
|
||||
});
|
||||
|
||||
await promptForEmailUnverified();
|
||||
|
||||
expect(input).not.toHaveBeenCalled();
|
||||
// save consent is now called after validation, not in promptForEmailUnverified
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prompt for email and save valid input', async () => {
|
||||
vi.mocked(input).mockResolvedValue('new@example.com');
|
||||
|
||||
await promptForEmailUnverified();
|
||||
|
||||
expect(writeGlobalConfigPartial).toHaveBeenCalledWith({
|
||||
account: { email: 'new@example.com' },
|
||||
});
|
||||
// save consent is now called after validation, not in promptForEmailUnverified
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw a recoverable error when the prompt is cancelled', async () => {
|
||||
vi.mocked(input).mockRejectedValue({ name: 'ExitPromptError' });
|
||||
|
||||
await expect(promptForEmailUnverified()).rejects.toThrowError(
|
||||
new EmailValidationError('prompt_cancelled', 'Email prompt cancelled.'),
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
// Ctrl+C should remain a silent exit; no error/warn output.
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('email validation', () => {
|
||||
let validateFn: (input: string) => Promise<string | boolean>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await promptForEmailUnverified();
|
||||
validateFn = vi.mocked(input).mock.calls[0][0].validate as (
|
||||
input: string,
|
||||
) => Promise<string | boolean>;
|
||||
});
|
||||
|
||||
it('should reject invalid email formats with error message', async () => {
|
||||
const invalidEmails = [
|
||||
'',
|
||||
'invalid',
|
||||
'@example.com',
|
||||
'user@',
|
||||
'user@.',
|
||||
'user.com',
|
||||
'user@.com',
|
||||
'@.',
|
||||
'user@example.',
|
||||
'user.@example.com',
|
||||
'us..er@example.com',
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
const result = await validateFn(email);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toBe('Invalid email address');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid email formats with true', async () => {
|
||||
const validEmails = [
|
||||
'valid@example.com',
|
||||
'user.name@example.com',
|
||||
'user+tag@example.com',
|
||||
'user@subdomain.example.com',
|
||||
'user@example.co.uk',
|
||||
'123@example.com',
|
||||
'user-name@example.com',
|
||||
'user_name@example.com',
|
||||
];
|
||||
|
||||
for (const email of validEmails) {
|
||||
await expect(validateFn(email)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkEmailStatusAndMaybeExit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('should bypass email verification checks for the CI placeholder email', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
|
||||
await checkEmailStatusAndMaybeExit();
|
||||
|
||||
expect(fetchWithTimeout).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use user email when not in CI environment', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await checkEmailStatusAndMaybeExit();
|
||||
|
||||
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/users/status?email=test%40example.com'),
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a recoverable error if limit exceeded', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'exceeded_limit' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(checkEmailStatusAndMaybeExit()).rejects.toThrowError(
|
||||
new EmailValidationError(
|
||||
'exceeded_limit',
|
||||
'You have exceeded the maximum cloud inference limit. Please contact inquiries@promptfoo.dev to upgrade your account.',
|
||||
),
|
||||
);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'You have exceeded the maximum cloud inference limit. Please contact inquiries@promptfoo.dev to upgrade your account.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a recoverable error if email verification is required', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const mockResponse = new Response(
|
||||
JSON.stringify({
|
||||
status: 'email_verification_required',
|
||||
error: 'Please verify your email address and try again.',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
},
|
||||
);
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(checkEmailStatusAndMaybeExit()).rejects.toThrowError(
|
||||
new EmailValidationError(
|
||||
'email_verification_required',
|
||||
'Please verify your email address and try again.',
|
||||
),
|
||||
);
|
||||
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Please verify your email address and try again.',
|
||||
expect.objectContaining({
|
||||
status: 'email_verification_required',
|
||||
hasEmail: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display warning message when status is show_usage_warning', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const warningMessage = 'You are approaching your usage limit';
|
||||
const mockResponse = new Response(
|
||||
JSON.stringify({ status: 'show_usage_warning', message: warningMessage }),
|
||||
{
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
},
|
||||
);
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await checkEmailStatusAndMaybeExit();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(chalk.yellow(warningMessage));
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return bad_email and not exit when status is risky_email or disposable_email', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'risky_email' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await checkEmailStatusAndMaybeExit();
|
||||
|
||||
expect(result).toBe('bad_email');
|
||||
expect(logger.error).toHaveBeenCalledWith('Please use a valid work email.');
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
id: 'test-id',
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await checkEmailStatusAndMaybeExit();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Failed to check user status: Error: Network error',
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkEmailStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return no_email status when no email is provided', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({});
|
||||
|
||||
const result = await checkEmailStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'no_email',
|
||||
hasEmail: false,
|
||||
message: 'Redteam evals require email verification. Please enter your work email:',
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat the CI placeholder email as pre-validated', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
|
||||
const result = await checkEmailStatus();
|
||||
|
||||
expect(fetchWithTimeout).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
hasEmail: true,
|
||||
email: 'ci-placeholder@promptfoo.dev',
|
||||
message: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return exceeded_limit status', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'exceeded_limit' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await checkEmailStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'exceeded_limit',
|
||||
hasEmail: true,
|
||||
email: 'test@example.com',
|
||||
message: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return show_usage_warning status with message', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
|
||||
const warningMessage = 'You are approaching your usage limit';
|
||||
const mockResponse = new Response(
|
||||
JSON.stringify({ status: 'show_usage_warning', message: warningMessage }),
|
||||
{
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
},
|
||||
);
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await checkEmailStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'show_usage_warning',
|
||||
hasEmail: true,
|
||||
email: 'test@example.com',
|
||||
message: warningMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await checkEmailStatus();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Failed to check user status: Error: Network error',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
hasEmail: true,
|
||||
email: 'test@example.com',
|
||||
message: 'Unable to verify email status, but proceeding',
|
||||
});
|
||||
});
|
||||
|
||||
describe('with validate option', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call saveConsent for valid email when validate is true', async () => {
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await checkEmailStatus({ validate: true });
|
||||
|
||||
expect(telemetry.saveConsent).toHaveBeenCalledWith('test@example.com', {
|
||||
source: 'promptForEmailValidated',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip remote validation for the CI placeholder email', async () => {
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
|
||||
const result = await checkEmailStatus({ validate: true });
|
||||
|
||||
expect(fetchWithTimeout).not.toHaveBeenCalled();
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
status: 'ok',
|
||||
hasEmail: true,
|
||||
email: 'ci-placeholder@promptfoo.dev',
|
||||
message: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call saveConsent for invalid email when validate is true', async () => {
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'risky_email' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await checkEmailStatus({ validate: true });
|
||||
|
||||
expect(telemetry.saveConsent).toHaveBeenCalledWith('test@example.com', {
|
||||
source: 'filteredInvalidEmail',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mark email as validated when verification is required', async () => {
|
||||
const mockResponse = new Response(
|
||||
JSON.stringify({
|
||||
status: 'email_verification_required',
|
||||
error: 'Please verify your email address and try again.',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
},
|
||||
);
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await checkEmailStatus({ validate: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'email_verification_required',
|
||||
hasEmail: true,
|
||||
email: 'test@example.com',
|
||||
message: 'Please verify your email address and try again.',
|
||||
});
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call saveConsent when validate is not provided', async () => {
|
||||
const mockResponse = new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(mockResponse);
|
||||
|
||||
await checkEmailStatus();
|
||||
|
||||
expect(telemetry.saveConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkEmailStatus host resolution (on-prem)', () => {
|
||||
const ONPREM_HOST = 'https://onprem.example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
});
|
||||
vi.mocked(fetchWithTimeout).mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' }), { status: 200, statusText: 'OK' }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sends the email-status request to the configured cloud host when cloud is enabled', async () => {
|
||||
vi.spyOn(cloudConfig, 'isEnabled').mockReturnValue(true);
|
||||
vi.spyOn(cloudConfig, 'getApiHost').mockReturnValue(ONPREM_HOST);
|
||||
|
||||
await checkEmailStatus();
|
||||
|
||||
expect(fetchWithTimeout).toHaveBeenCalledTimes(1);
|
||||
const calledUrl = vi.mocked(fetchWithTimeout).mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(`${ONPREM_HOST}/api/users/status`);
|
||||
expect(calledUrl).not.toContain('api.promptfoo.app');
|
||||
});
|
||||
|
||||
it('does not consult getApiHost and uses PROMPTFOO_CLOUD_API_URL when cloud is not enabled', async () => {
|
||||
vi.spyOn(cloudConfig, 'isEnabled').mockReturnValue(false);
|
||||
const getApiHostSpy = vi.spyOn(cloudConfig, 'getApiHost');
|
||||
vi.mocked(getEnvString).mockImplementation((key: string, fallback?: any) =>
|
||||
key === 'PROMPTFOO_CLOUD_API_URL' ? 'https://env-cloud.example.com' : fallback,
|
||||
);
|
||||
|
||||
await checkEmailStatus();
|
||||
|
||||
const calledUrl = vi.mocked(fetchWithTimeout).mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('https://env-cloud.example.com/api/users/status');
|
||||
expect(getApiHostSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips a trailing slash from PROMPTFOO_CLOUD_API_URL on the non-cloud path', async () => {
|
||||
vi.spyOn(cloudConfig, 'isEnabled').mockReturnValue(false);
|
||||
vi.mocked(getEnvString).mockImplementation((key: string, fallback?: any) =>
|
||||
key === 'PROMPTFOO_CLOUD_API_URL' ? 'https://env-cloud.example.com/' : fallback,
|
||||
);
|
||||
|
||||
await checkEmailStatus();
|
||||
|
||||
const calledUrl = vi.mocked(fetchWithTimeout).mock.calls[0][0] as string;
|
||||
// No `//api/users/status` from the trailing slash.
|
||||
expect(calledUrl).toContain('https://env-cloud.example.com/api/users/status');
|
||||
expect(calledUrl).not.toContain('.com//api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoggedIntoCloud', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true when API key is present (not in CI)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
expect(isLoggedIntoCloud()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when API key is present (in CI)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
expect(isLoggedIntoCloud()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no API key is present (not in CI)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: {},
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
expect(isLoggedIntoCloud()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no API key is present (in CI)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: {},
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(true);
|
||||
expect(isLoggedIntoCloud()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cloud config is missing', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({});
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
expect(isLoggedIntoCloud()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true with email and API key (backwards compatibility)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
expect(isLoggedIntoCloud()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false with email but no API key', () => {
|
||||
// Having email alone is not sufficient for cloud authentication
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: {},
|
||||
});
|
||||
vi.mocked(isCI).mockReturnValue(false);
|
||||
expect(isLoggedIntoCloud()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return "api-key" when API key is present', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthMethod()).toBe('api-key');
|
||||
});
|
||||
|
||||
it('should return "api-key" when both API key and email are present', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: { apiKey: 'test-api-key' },
|
||||
});
|
||||
expect(getAuthMethod()).toBe('api-key');
|
||||
});
|
||||
|
||||
it('should return "email" when only email is present (no API key)', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
account: { email: 'test@example.com' },
|
||||
cloud: {},
|
||||
});
|
||||
expect(getAuthMethod()).toBe('email');
|
||||
});
|
||||
|
||||
it('should return "none" when neither API key nor email is present', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({
|
||||
cloud: {},
|
||||
});
|
||||
expect(getAuthMethod()).toBe('none');
|
||||
});
|
||||
|
||||
it('should return "none" when cloud config is missing', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue({});
|
||||
expect(getAuthMethod()).toBe('none');
|
||||
});
|
||||
|
||||
it('should return "none" when global config is null', () => {
|
||||
vi.mocked(readGlobalConfig).mockReturnValue(null as any);
|
||||
expect(getAuthMethod()).toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,760 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
readRuntimeNoticeLastShownAt,
|
||||
withRuntimeNoticeStateLock,
|
||||
writeRuntimeNoticeLastShownAt,
|
||||
} from '../../src/globalConfig/runtimeNoticeState';
|
||||
import { setConfigDirectoryPath } from '../../src/util/config/manage';
|
||||
|
||||
describe('runtime notice state', () => {
|
||||
let configDirectory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-runtime-notice-'));
|
||||
setConfigDirectoryPath(configDirectory);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setConfigDirectoryPath(undefined);
|
||||
fs.rmSync(configDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('stores notice timestamps separately without rewriting global config', () => {
|
||||
const globalConfigPath = path.join(configDirectory, 'promptfoo.yaml');
|
||||
const globalConfig = 'account:\n email: current@example.com\n';
|
||||
fs.writeFileSync(globalConfigPath, globalConfig);
|
||||
|
||||
writeRuntimeNoticeLastShownAt('node20-removal-2026-07-30', '2026-06-22T12:00:00.000Z');
|
||||
|
||||
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBe(
|
||||
'2026-06-22T12:00:00.000Z',
|
||||
);
|
||||
writeRuntimeNoticeLastShownAt('node20-removal-2026-07-30', '2026-06-29T12:00:00.000Z');
|
||||
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBe(
|
||||
'2026-06-29T12:00:00.000Z',
|
||||
);
|
||||
expect(fs.readFileSync(globalConfigPath, 'utf8')).toBe(globalConfig);
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([
|
||||
'node20-removal-2026-07-30.last-shown',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats missing notice state as not previously shown', () => {
|
||||
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves prior state and cleans up when atomic replacement fails', () => {
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
writeRuntimeNoticeLastShownAt(noticeId, '2026-06-22T12:00:00.000Z');
|
||||
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(() => {
|
||||
throw new Error('Atomic replacement failed');
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => writeRuntimeNoticeLastShownAt(noticeId, '2026-06-29T12:00:00.000Z')).toThrow(
|
||||
'Atomic replacement failed',
|
||||
);
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(readRuntimeNoticeLastShownAt(noticeId)).toBe('2026-06-22T12:00:00.000Z');
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([
|
||||
'node20-removal-2026-07-30.last-shown',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects notice ids that could escape the state directory', () => {
|
||||
expect(() => readRuntimeNoticeLastShownAt('../promptfoo.yaml')).toThrow(
|
||||
'Invalid runtime notice id',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only one concurrent state transition for a notice', () => {
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
let nestedResult: boolean | undefined;
|
||||
|
||||
const outerResult = withRuntimeNoticeStateLock(noticeId, () => {
|
||||
nestedResult = withRuntimeNoticeStateLock(noticeId, () => true);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(outerResult).toBe(true);
|
||||
expect(nestedResult).toBeUndefined();
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('releases held locks when the transition callback throws', () => {
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
|
||||
expect(() =>
|
||||
withRuntimeNoticeStateLock(noticeId, () => {
|
||||
throw new Error('transition failed');
|
||||
}),
|
||||
).toThrow('transition failed');
|
||||
|
||||
// The finally block must release both generation locks even when the callback throws, so a
|
||||
// failed transition (e.g. an unreadable config) cannot wedge the notice for the lease window.
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat lock creation errors as ordinary contention', () => {
|
||||
const lockError = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
||||
const openSpy = vi.spyOn(fs, 'openSync').mockImplementation(() => {
|
||||
throw lockError;
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => withRuntimeNoticeStateLock('node20-removal-2026-07-30', () => true)).toThrow(
|
||||
lockError,
|
||||
);
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a transition exclusive across a lease generation boundary', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-06-22T12:00:29.999Z'));
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
let nextLeaseResult: string | undefined;
|
||||
|
||||
expect(
|
||||
withRuntimeNoticeStateLock(noticeId, () => {
|
||||
const noticeDirectory = path.join(configDirectory, 'notices');
|
||||
expect(
|
||||
fs.readdirSync(noticeDirectory).filter((entry) => entry.includes('.lock.')),
|
||||
).toHaveLength(2);
|
||||
vi.setSystemTime(new Date('2026-06-22T12:00:30.000Z'));
|
||||
|
||||
nextLeaseResult = withRuntimeNoticeStateLock(noticeId, () => 'claimed');
|
||||
return 'original';
|
||||
}),
|
||||
).toBe('original');
|
||||
|
||||
expect(nextLeaseResult).toBeUndefined();
|
||||
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBe('claimed');
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores abandoned lease files after the protected generations pass', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const initialTime = new Date('2026-06-22T12:00:00.000Z');
|
||||
vi.setSystemTime(initialTime);
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
const noticeDirectory = path.join(configDirectory, 'notices');
|
||||
fs.mkdirSync(noticeDirectory, { recursive: true });
|
||||
const generation = Math.floor(initialTime.getTime() / 30_000);
|
||||
const abandonedLocks = [generation, generation + 1].map((value) =>
|
||||
path.join(noticeDirectory, `${noticeId}.last-shown.lock.${value}`),
|
||||
);
|
||||
for (const lockPath of abandonedLocks) {
|
||||
fs.writeFileSync(lockPath, 'abandoned\n');
|
||||
}
|
||||
|
||||
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBeUndefined();
|
||||
|
||||
vi.setSystemTime(new Date(initialTime.getTime() + 60_000));
|
||||
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBe('claimed');
|
||||
expect(fs.readdirSync(noticeDirectory).sort()).toEqual(
|
||||
abandonedLocks.map((lockPath) => path.basename(lockPath)).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a transition exclusive across an adjacent clock rollback', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-06-22T12:01:00.000Z'));
|
||||
const noticeId = 'node20-removal-2026-07-30';
|
||||
let rollbackResult: string | undefined;
|
||||
|
||||
withRuntimeNoticeStateLock(noticeId, () => {
|
||||
vi.setSystemTime(new Date('2026-06-22T12:00:59.999Z'));
|
||||
rollbackResult = withRuntimeNoticeStateLock(noticeId, () => 'claimed');
|
||||
});
|
||||
|
||||
expect(rollbackResult).toBeUndefined();
|
||||
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user