Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/commands/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

// Mock debug utilities
vi.mock('../utils/debug.js', () => ({
logInfo: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));

// Mock clack prompts
vi.mock('../utils/clack.js', () => ({
default: {
log: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
step: vi.fn(),
},
spinner: vi.fn(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
isCancel: vi.fn(() => false),
},
}));

// Mock staging API — we control it per test
const mockFetchStagingCredentials = vi.fn();
vi.mock('../lib/staging-api.js', () => ({
fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args),
}));

let testDir: string;

vi.mock('node:os', async (importOriginal) => {
const original = await importOriginal<typeof import('node:os')>();
return {
...original,
default: {
...original,
homedir: () => testDir,
},
homedir: () => testDir,
};
});

const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js');
const { provisionStagingEnvironment } = await import('./login.js');

describe('login', () => {
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'login-test-'));
setInsecureConfigStorage(true);
vi.clearAllMocks();
});

afterEach(() => {
clearConfig();
try {
rmdirSync(join(testDir, '.workos'), { recursive: true });
} catch {}
try {
rmdirSync(testDir);
} catch {}
});

describe('provisionStagingEnvironment', () => {
it('creates a staging environment on success', async () => {
mockFetchStagingCredentials.mockResolvedValueOnce({
clientId: 'client_staging_123',
apiKey: 'sk_test_staging_abc',
});

const result = await provisionStagingEnvironment('access_token_xyz');

expect(result).toBe(true);
expect(mockFetchStagingCredentials).toHaveBeenCalledWith('access_token_xyz');

const config = getConfig();
expect(config).not.toBeNull();
expect(config?.environments['staging']).toEqual({
name: 'staging',
type: 'sandbox',
apiKey: 'sk_test_staging_abc',
clientId: 'client_staging_123',
});
});

it('sets staging as active environment when no environments exist', async () => {
mockFetchStagingCredentials.mockResolvedValueOnce({
clientId: 'client_123',
apiKey: 'sk_test_abc',
});

await provisionStagingEnvironment('token');

const config = getConfig();
expect(config?.activeEnvironment).toBe('staging');
});

it('does not change active environment when one already exists', async () => {
// Pre-create an environment
const { saveConfig } = await import('../lib/config-store.js');
saveConfig({
activeEnvironment: 'production',
environments: {
production: {
name: 'production',
type: 'production',
apiKey: 'sk_live_existing',
},
},
});

mockFetchStagingCredentials.mockResolvedValueOnce({
clientId: 'client_123',
apiKey: 'sk_test_abc',
});

await provisionStagingEnvironment('token');

const config = getConfig();
expect(config?.activeEnvironment).toBe('production');
expect(config?.environments['staging']).toBeDefined();
expect(config?.environments['production']).toBeDefined();
});

it('updates existing staging environment if already present', async () => {
const { saveConfig } = await import('../lib/config-store.js');
saveConfig({
activeEnvironment: 'staging',
environments: {
staging: {
name: 'staging',
type: 'sandbox',
apiKey: 'sk_test_old',
clientId: 'client_old',
},
},
});

mockFetchStagingCredentials.mockResolvedValueOnce({
clientId: 'client_new',
apiKey: 'sk_test_new',
});

const result = await provisionStagingEnvironment('token');

expect(result).toBe(true);
const config = getConfig();
expect(config?.environments['staging']?.apiKey).toBe('sk_test_new');
expect(config?.environments['staging']?.clientId).toBe('client_new');
});

it('returns false and does not throw on API 403 error', async () => {
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Access denied'));

const result = await provisionStagingEnvironment('token');

expect(result).toBe(false);
const config = getConfig();
expect(config).toBeNull();
});

it('returns false and does not throw on API 404 error', async () => {
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('No staging environment found'));

const result = await provisionStagingEnvironment('token');

expect(result).toBe(false);
});

it('returns false and does not throw on network error', async () => {
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Network error'));

const result = await provisionStagingEnvironment('token');

expect(result).toBe(false);
});

it('returns false and does not throw on timeout', async () => {
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Request timed out'));

const result = await provisionStagingEnvironment('token');

expect(result).toBe(false);
});
});
});
47 changes: 46 additions & 1 deletion src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import clack from '../utils/clack.js';
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
import { refreshAccessToken } from '../lib/token-refresh-client.js';
import { logInfo } from '../utils/debug.js';
import { logInfo, logError } from '../utils/debug.js';
import { fetchStagingCredentials } from '../lib/staging-api.js';
import { getConfig, saveConfig } from '../lib/config-store.js';
import type { CliConfig } from '../lib/config-store.js';

/**
* Parse JWT payload
Expand Down Expand Up @@ -66,6 +69,40 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Auto-provision a staging environment after login.
*
* Fetches staging credentials using the access token, then saves them
* as a "staging" environment in the config store. Non-fatal — logs a
* hint on failure instead of throwing.
*/
export async function provisionStagingEnvironment(accessToken: string): Promise<boolean> {
try {
const staging = await fetchStagingCredentials(accessToken);

const config: CliConfig = getConfig() ?? { environments: {} };
const isFirst = Object.keys(config.environments).length === 0;

config.environments['staging'] = {
name: 'staging',
type: 'sandbox',
apiKey: staging.apiKey,
clientId: staging.clientId,
};

if (isFirst || !config.activeEnvironment) {
config.activeEnvironment = 'staging';
}

saveConfig(config);
logInfo('[login] Staging environment auto-provisioned');
return true;
} catch (error) {
logError('[login] Failed to auto-provision staging environment:', error instanceof Error ? error.message : error);
return false;
}
}

export async function runLogin(): Promise<void> {
const clientId = getCliAuthClientId();

Expand Down Expand Up @@ -184,6 +221,14 @@ export async function runLogin(): Promise<void> {
spinner.stop('Authentication successful!');
clack.log.success(`Logged in as ${email || userId}`);
clack.log.info(`Token expires in ${expiresInSec} seconds`);

// Auto-provision staging environment
const provisioned = await provisionStagingEnvironment(result.access_token);
if (provisioned) {
clack.log.success('Staging environment configured automatically');
} else {
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
}
return;
}

Expand Down