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
811 lines
28 KiB
TypeScript
811 lines
28 KiB
TypeScript
import search from '@inquirer/search';
|
|
import { Command } from 'commander';
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { authCommand } from '../../src/commands/auth';
|
|
import { isNonInteractive } from '../../src/envars';
|
|
import { getUserEmail, setUserEmail } from '../../src/globalConfig/accounts';
|
|
import { cloudConfig } from '../../src/globalConfig/cloud';
|
|
import logger from '../../src/logger';
|
|
import { getDefaultTeam, getUserTeams } from '../../src/util/cloud';
|
|
import { fetchWithProxy } from '../../src/util/fetch/index';
|
|
import { openAuthBrowser } from '../../src/util/server';
|
|
import { createMockResponse, mockGlobal, stripAnsi } from '../util/utils';
|
|
|
|
vi.mock('@inquirer/search');
|
|
|
|
const mockCloudUser = {
|
|
id: '1',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockOrganization = {
|
|
id: '1',
|
|
name: 'Test Org',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockApp = {
|
|
id: '1',
|
|
name: 'Test App',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
url: 'https://app.example.com',
|
|
};
|
|
|
|
vi.mock('../../src/envars');
|
|
vi.mock('../../src/globalConfig/accounts');
|
|
vi.mock('../../src/globalConfig/cloud');
|
|
vi.mock('../../src/logger');
|
|
vi.mock('../../src/util/cloud');
|
|
vi.mock('../../src/util/fetch/index.ts');
|
|
vi.mock('../../src/util/server');
|
|
|
|
const mockFetch = vi.fn();
|
|
const restoreFetch = mockGlobal('fetch', mockFetch);
|
|
|
|
afterAll(() => {
|
|
restoreFetch();
|
|
});
|
|
|
|
describe('auth command', () => {
|
|
let program: Command;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.resetAllMocks();
|
|
program = new Command();
|
|
process.exitCode = undefined;
|
|
authCommand(program);
|
|
|
|
// Set up a basic mock that just returns the expected data
|
|
vi.mocked(cloudConfig.validateApiToken).mockResolvedValue({
|
|
user: mockCloudUser,
|
|
organization: mockOrganization,
|
|
app: mockApp,
|
|
hasActiveLicense: false,
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('should set email in config after successful login with API key', async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
createMockResponse({
|
|
ok: true,
|
|
body: { user: mockCloudUser },
|
|
}),
|
|
);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(setUserEmail).toHaveBeenCalledWith('test@example.com');
|
|
expect(cloudConfig.validateApiToken).toHaveBeenCalledWith('test-key', undefined);
|
|
expect(cloudConfig.saveValidatedApiToken).toHaveBeenCalledWith(
|
|
'test-key',
|
|
undefined,
|
|
mockCloudUser,
|
|
mockApp,
|
|
false,
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully logged in'));
|
|
});
|
|
|
|
it('should prompt for browser opening when no API key is provided in interactive environment', async () => {
|
|
// Mock interactive environment
|
|
vi.mocked(isNonInteractive).mockReturnValue(false);
|
|
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://www.promptfoo.app');
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(openAuthBrowser).toHaveBeenCalledWith(
|
|
'https://www.promptfoo.app/',
|
|
'https://www.promptfoo.app/welcome',
|
|
0, // BrowserBehavior.ASK
|
|
);
|
|
});
|
|
|
|
it('should exit with error when no API key is provided in non-interactive environment', async () => {
|
|
// Mock non-interactive environment (CI, cron, SSH without TTY, etc.)
|
|
vi.mocked(isNonInteractive).mockReturnValue(true);
|
|
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://www.promptfoo.app');
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Authentication required. Please set PROMPTFOO_API_KEY environment variable or run `promptfoo auth login` in an interactive environment.',
|
|
);
|
|
// Check that both info calls were made
|
|
const infoCalls = vi.mocked(logger.info).mock.calls;
|
|
const infoMessages = infoCalls.map((call) => stripAnsi(String(call[0])));
|
|
expect(infoCalls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
expect(
|
|
infoMessages.some((message) =>
|
|
message.includes('Manual login URL: https://www.promptfoo.app/'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
infoMessages.some((message) =>
|
|
message.includes('After login, get your API token at: https://www.promptfoo.app/welcome'),
|
|
),
|
|
).toBe(true);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(openAuthBrowser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use custom host for browser opening when provided in interactive environment', async () => {
|
|
// Mock interactive environment
|
|
vi.mocked(isNonInteractive).mockReturnValue(false);
|
|
const customHost = 'https://custom.promptfoo.com';
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--host', customHost]);
|
|
|
|
expect(openAuthBrowser).toHaveBeenCalledWith(
|
|
'https://custom.promptfoo.com/',
|
|
'https://custom.promptfoo.com/welcome',
|
|
0, // BrowserBehavior.ASK
|
|
);
|
|
});
|
|
|
|
it('should use custom host when provided', async () => {
|
|
const customHost = 'https://custom-api.example.com';
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--host', customHost]);
|
|
|
|
expect(cloudConfig.validateApiToken).toHaveBeenCalledWith('test-key', customHost);
|
|
expect(cloudConfig.saveValidatedApiToken).toHaveBeenCalledWith(
|
|
'test-key',
|
|
customHost,
|
|
mockCloudUser,
|
|
mockApp,
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should handle login request failure', async () => {
|
|
vi.mocked(cloudConfig.validateApiToken).mockRejectedValueOnce(new Error('Bad Request'));
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('Authentication failed: Bad Request'),
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('should overwrite existing email in config after successful login', async () => {
|
|
const newCloudUser = { ...mockCloudUser, email: 'new@example.com' };
|
|
vi.mocked(getUserEmail).mockReturnValue('old@example.com');
|
|
vi.mocked(cloudConfig.validateApiToken).mockResolvedValueOnce({
|
|
user: newCloudUser,
|
|
organization: mockOrganization,
|
|
app: mockApp,
|
|
hasActiveLicense: false,
|
|
});
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(setUserEmail).toHaveBeenCalledWith('new@example.com');
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining('Updating local email configuration'),
|
|
);
|
|
});
|
|
|
|
it('should handle non-Error objects in the catch block', async () => {
|
|
vi.mocked(cloudConfig.validateApiToken).mockImplementationOnce(function () {
|
|
// Intentionally throw a non-Error value to exercise defensive error-message handling.
|
|
throw 'String error message';
|
|
});
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('Authentication failed: String error message'),
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
|
|
// Reset exitCode
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it('should use --team flag to set specific team', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--team', 'security']);
|
|
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Security Team'));
|
|
});
|
|
|
|
it('should resolve --team across organizations when --org is omitted', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--team', 'security']);
|
|
|
|
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
|
|
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use the custom host when resolving --team before saving API key login', async () => {
|
|
const customHost = 'https://api.promptfoo.example';
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync([
|
|
'node',
|
|
'test',
|
|
'--api-key',
|
|
'test-key',
|
|
'--host',
|
|
customHost,
|
|
'--team',
|
|
'security',
|
|
]);
|
|
|
|
expect(getUserTeams).toHaveBeenCalledWith(customHost, 'test-key');
|
|
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
|
|
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
|
|
});
|
|
|
|
it('should scope team selection and current organization to --org', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'org-2']);
|
|
|
|
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
|
|
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
|
|
});
|
|
|
|
it('should prefer an exact team name over a slug match when --org and --team are provided', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Slug Match',
|
|
slug: 'shared',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Shared',
|
|
slug: 'shared-name',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync([
|
|
'node',
|
|
'test',
|
|
'--api-key',
|
|
'test-key',
|
|
'--org',
|
|
'org-2',
|
|
'--team',
|
|
'shared',
|
|
]);
|
|
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
|
|
});
|
|
|
|
it('should log and persist the resolved organization when the default org has no teams', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: 'org-2',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
|
|
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[0]], 'org-2');
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Organization:'));
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('org-2'));
|
|
});
|
|
|
|
it('should fail login when --org does not match any accessible team organization', async () => {
|
|
vi.mocked(getUserTeams).mockResolvedValue([
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
]);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'missing-org']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"Authentication failed: Organization 'missing-org' not found in your accessible teams.",
|
|
),
|
|
);
|
|
expect(cloudConfig.saveValidatedApiToken).not.toHaveBeenCalled();
|
|
expect(setUserEmail).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('should reject unknown --org when no teams are returned', async () => {
|
|
vi.mocked(getUserTeams).mockResolvedValue([]);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'missing-org']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"Authentication failed: Organization 'missing-org' not found in your accessible teams. Available organizations: 1",
|
|
),
|
|
);
|
|
expect(cloudConfig.saveValidatedApiToken).not.toHaveBeenCalled();
|
|
expect(setUserEmail).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('should auto-select single team without prompting', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Only Team',
|
|
slug: 'only',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(search).not.toHaveBeenCalled();
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-1', '1');
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Only Team'));
|
|
});
|
|
|
|
it('should prompt for team selection when multiple teams exist in interactive mode', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security-alpha',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
vi.mocked(isNonInteractive).mockReturnValue(false);
|
|
vi.mocked(search).mockResolvedValue('team-2');
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(search).toHaveBeenCalledWith({
|
|
message: 'Select a team to use:',
|
|
source: expect.any(Function),
|
|
});
|
|
|
|
const source = vi.mocked(search).mock.calls[0]?.[0].source;
|
|
const signal = new AbortController().signal;
|
|
await expect(source?.(undefined, { signal })).resolves.toEqual([
|
|
expect.objectContaining({ name: 'Default', value: 'team-1', description: 'default' }),
|
|
expect.objectContaining({
|
|
name: 'Security Team',
|
|
value: 'team-2',
|
|
description: 'security-alpha',
|
|
}),
|
|
]);
|
|
await expect(source?.('sec', { signal })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
name: 'Security Team',
|
|
value: 'team-2',
|
|
description: 'security-alpha',
|
|
}),
|
|
]);
|
|
await expect(source?.('alpha', { signal })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
name: 'Security Team',
|
|
value: 'team-2',
|
|
description: 'security-alpha',
|
|
}),
|
|
]);
|
|
await expect(source?.('DEFAULT', { signal })).resolves.toEqual([
|
|
expect.objectContaining({ name: 'Default', value: 'team-1', description: 'default' }),
|
|
]);
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
|
|
});
|
|
|
|
it('should use default team with warning in non-interactive mode when multiple teams exist', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
vi.mocked(isNonInteractive).mockReturnValue(true);
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(search).not.toHaveBeenCalled();
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('You have access to 2 teams'),
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('--team flag'));
|
|
});
|
|
|
|
it('should fall back to default team when user cancels interactive selection', async () => {
|
|
const mockTeams = [
|
|
{
|
|
id: 'team-1',
|
|
name: 'Default',
|
|
slug: 'default',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-02',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
{
|
|
id: 'team-2',
|
|
name: 'Security Team',
|
|
slug: 'security',
|
|
organizationId: '1',
|
|
createdAt: '2024-01-01',
|
|
updatedAt: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
|
|
vi.mocked(isNonInteractive).mockReturnValue(false);
|
|
vi.mocked(search).mockRejectedValue(new Error('User cancelled'));
|
|
|
|
const loginCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'login');
|
|
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
|
|
|
|
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('(default)'));
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('should unset email and delete cloud config after logout', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue('api-key');
|
|
|
|
const logoutCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'logout');
|
|
await logoutCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(cloudConfig.delete).toHaveBeenCalledWith();
|
|
expect(setUserEmail).toHaveBeenCalledWith('');
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully logged out'));
|
|
});
|
|
|
|
it('should show "already logged out" message when no session exists', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue(null);
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
|
|
|
|
const logoutCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'logout');
|
|
await logoutCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(cloudConfig.delete).not.toHaveBeenCalled();
|
|
expect(setUserEmail).not.toHaveBeenCalled();
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining("You're already logged out"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('whoami', () => {
|
|
it('should show user info when logged in', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
|
|
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
|
|
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://app.example.com');
|
|
|
|
vi.mocked(getDefaultTeam).mockResolvedValueOnce({
|
|
id: 'team-1',
|
|
name: 'Default Team',
|
|
organizationId: 'org-1',
|
|
createdAt: '2023-01-01T00:00:00Z',
|
|
});
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
|
|
createMockResponse({
|
|
ok: true,
|
|
body: {
|
|
user: mockCloudUser,
|
|
organization: mockOrganization,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const whoamiCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'whoami');
|
|
await whoamiCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Currently logged in as:'));
|
|
});
|
|
|
|
it('should handle not logged in state', async () => {
|
|
// Reset logger mock before test
|
|
vi.mocked(logger.info).mockClear();
|
|
|
|
vi.mocked(getUserEmail).mockReturnValue(null);
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
|
|
|
|
const whoamiCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'whoami');
|
|
await whoamiCmd?.parseAsync(['node', 'test']);
|
|
|
|
// Get the actual logged message
|
|
const infoMessages = vi.mocked(logger.info).mock.calls.map((call) => call[0]);
|
|
|
|
// Verify it contains our expected text
|
|
expect(infoMessages).toHaveLength(1);
|
|
expect(infoMessages[0]).toContain('Not logged in');
|
|
expect(infoMessages[0]).toContain('promptfoo auth login');
|
|
|
|
// No telemetry is recorded in this case (as per implementation)
|
|
});
|
|
|
|
it('should handle API error', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
|
|
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
|
|
createMockResponse({
|
|
ok: false,
|
|
statusText: 'Internal Server Error',
|
|
}),
|
|
);
|
|
|
|
const whoamiCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'whoami');
|
|
await whoamiCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Failed to get user info: Failed to fetch user info: Internal Server Error',
|
|
),
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it('should handle failed API response with empty body', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
|
|
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
|
|
|
|
// Mock response with an empty body to exercise error-body fallback handling.
|
|
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
|
|
createMockResponse({
|
|
ok: false,
|
|
statusText: 'Internal Server Error',
|
|
// Providing no body or an empty body
|
|
}),
|
|
);
|
|
|
|
const whoamiCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'whoami');
|
|
await whoamiCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Failed to get user info: Failed to fetch user info: Internal Server Error',
|
|
),
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
|
|
// Reset exitCode
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it('should handle non-Error object in the catch block', async () => {
|
|
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
|
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
|
|
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
|
|
|
|
// Intentionally throw a non-Error value to exercise defensive error-message handling.
|
|
vi.mocked(fetchWithProxy).mockImplementationOnce(function () {
|
|
throw 'String error from fetch';
|
|
});
|
|
|
|
const whoamiCmd = program.commands
|
|
.find((cmd) => cmd.name() === 'auth')
|
|
?.commands.find((cmd) => cmd.name() === 'whoami');
|
|
await whoamiCmd?.parseAsync(['node', 'test']);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith('Failed to get user info: String error from fetch');
|
|
expect(process.exitCode).toBe(1);
|
|
|
|
// Reset exitCode
|
|
process.exitCode = 0;
|
|
});
|
|
});
|
|
});
|