Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

960 lines
31 KiB
TypeScript

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');
});
});
});