diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts new file mode 100644 index 0000000..bb15df3 --- /dev/null +++ b/src/commands/login.spec.ts @@ -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(); + 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); + }); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 6c612b0..e1d0efa 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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 @@ -66,6 +69,40 @@ function sleep(ms: number): Promise { 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 { + 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 { const clientId = getCliAuthClientId(); @@ -184,6 +221,14 @@ export async function runLogin(): Promise { 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; }