diff --git a/CLAUDE.md b/CLAUDE.md index 45ffe0a..4197293 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,13 +89,11 @@ Important note: Online documentation is available at https://docs.qasphere.com. Composable fetch wrappers using higher-order functions: -- `utils.ts` — `withBaseUrl`, `withApiKey`, `withJson`, `withDevAuth` decorators that wrap `fetch`; `jsonResponse()` for parsing responses; `appendSearchParams()` for building query strings; `resourceIdSchema` for validating resource identifiers; `printJson()` for formatted JSON output -- `index.ts` — `createApi(baseUrl, apiKey)` assembles the API client from all sub-modules +- `utils.ts` — `withBaseUrl`, `withAuth`, `withJson`, `withUserAgent`, `withHttpRetry` decorators that wrap `fetch` via middleware pattern; `jsonResponse()` for parsing responses; `appendSearchParams()` for building query strings +- `index.ts` — `createApi(baseUrl, token, authType)` assembles the API client from all sub-modules using `withFetchMiddlewares` - `schemas.ts` — Shared types (`ResourceId`, `ResultStatus`, `PaginatedResponse`, `PaginatedRequest`, `MessageResponse`), `RequestValidationError` class, `validateRequest()` helper, and common Zod field definitions (`sortFieldParam`, `sortOrderParam`, `pageParam`, `limitParam`) - One sub-module per resource (e.g., `projects.ts`, `runs.ts`, `tcases.ts`, `folders.ts`), each exporting a `createApi(fetcher)` factory function. Each module defines Zod schemas for its request types (PascalCase, e.g., `CreateRunRequestSchema`), derives TypeScript types via `z.infer`, and validates inputs with `validateRequest()` inside API functions -The main `createApi()` composes the fetch chain: `withDevAuth(withApiKey(withBaseUrl(fetch, baseUrl), apiKey))`. - ### Configuration (src/utils/) - `env.ts` — Loads `QAS_TOKEN` and `QAS_URL` from environment variables, `.env`, or `.qaspherecli` (searched up the directory tree). Optional `QAS_DEV_AUTH` adds a dev cookie via the `withDevAuth` fetch decorator diff --git a/README.md b/README.md index 2be0015..5942f82 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ - [Via NPX](#via-npx) - [Via NPM](#via-npm) - [Shell Completion](#shell-completion) -- [Environment](#environment) +- [Authentication](#authentication) + - [Other auth commands](#other-auth-commands) + - [Credential resolution order](#credential-resolution-order) + - [Manual configuration](#manual-configuration) - [Command: `api`](#command-api) - [API Command Tree](#api-command-tree) - [Commands: `junit-upload`, `playwright-json-upload`, `allure-upload`](#commands-junit-upload-playwright-json-upload-allure-upload) @@ -23,6 +26,8 @@ - [JUnit XML](#junit-xml) - [Playwright JSON](#playwright-json) - [Allure](#allure) + - [Run-Level Logs](#run-level-logs) +- [AI Agent Skill](#ai-agent-skill) - [Development](#development-for-those-who-want-to-contribute-to-the-tool) ## Description @@ -73,29 +78,46 @@ qasphere completion >> ~/.bashrc Then restart your shell or source the profile (e.g., `source ~/.zshrc`). After that, pressing `Tab` will autocomplete commands and options. -## Environment +## Authentication -The CLI requires the following variables to be defined: +The recommended way to authenticate is using the interactive login command: -- `QAS_TOKEN` - QA Sphere API token (see [docs](https://docs.qasphere.com/api/authentication) if you need help generating one) -- `QAS_URL` - Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) +```bash +qasphere auth login +``` + +This opens your browser to complete authentication and securely stores your credentials in the system keyring. If a keyring is not available, credentials are stored in `~/.config/qasphere/credentials.json` with restricted file permissions. + +### Other auth commands + +```bash +qasphere auth status # Show current authentication status +qasphere auth logout # Clear stored credentials +``` + +### Credential resolution order + +The CLI resolves credentials in the following order (first match wins): + +1. `QAS_TOKEN` and `QAS_URL` environment variables +2. `.env` file in the current working directory +3. System keyring (set by `qasphere auth login`) +4. `~/.config/qasphere/credentials.json` (fallback when keyring is unavailable) +5. `.qaspherecli` file in the current directory or any parent directory -These variables could be defined: +### Manual configuration -- as environment variables -- in .env of a current working directory -- in a special `.qaspherecli` configuration file in your project directory (or any parent directory) +Instead of using `auth login`, you can manually set the required variables: -Example: .qaspherecli +- `QAS_TOKEN` - QA Sphere API token (see [docs](https://docs.qasphere.com/api/authentication) if you need help generating one) +- `QAS_URL` - Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) + +These variables can be defined as environment variables, in a `.env` file, or in a `.qaspherecli` configuration file: ```sh # .qaspherecli QAS_TOKEN=your_token QAS_URL=https://qas.eu1.qasphere.com - -# Example with real values: -# QAS_TOKEN=qas.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest -# QAS_URL=https://qas.eu1.qasphere.com ``` ## Command: `api` @@ -369,7 +391,7 @@ Allure results use one `*-result.json` file per test in a results directory. `al Only Allure JSON result files (`*-result.json`) are supported. Legacy Allure 1 XML files are ignored. -## Run-Level Logs +### Run-Level Logs The CLI automatically detects global or suite-level failures and uploads them as run-level logs to QA Sphere. These failures are typically caused by setup/teardown issues that aren't tied to specific test cases. diff --git a/package-lock.json b/package-lock.json index 1a2117c..b398307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "qas-cli", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.5.0", + "version": "0.6.0", "license": "ISC", "dependencies": { + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "dotenv": "^16.5.0", "escape-html": "^1.0.3", @@ -453,6 +454,240 @@ "node": ">=18" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", + "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.2.0", + "@napi-rs/keyring-darwin-x64": "1.2.0", + "@napi-rs/keyring-freebsd-x64": "1.2.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", + "@napi-rs/keyring-linux-arm64-musl": "1.2.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-musl": "1.2.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", + "@napi-rs/keyring-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", + "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", + "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", diff --git a/package.json b/package.json index 2fca9c1..7096759 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "dotenv": "^16.5.0", "escape-html": "^1.0.3", diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md index 1ffbaf8..584113f 100644 --- a/skills/qas-cli/SKILL.md +++ b/skills/qas-cli/SKILL.md @@ -22,16 +22,35 @@ If working within the qas-cli repository itself, use `node build/bin/qasphere.js ## Prerequisites - **Node.js** 18.0.0+ -- **`QAS_TOKEN`** — QA Sphere API token ([how to generate](https://docs.qasphere.com/api/authentication)) -- **`QAS_URL`** — Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) -### Configuration Methods +### Authentication + +Two authentication methods are supported: + +#### Interactive Login (OAuth) + +```bash +qasphere auth login # Authenticate via browser-based OAuth device flow +qasphere auth status # Show current authentication status and token validity +qasphere auth logout # Clear stored credentials +``` + +`auth login` prompts for a team name, opens a browser for authorization, and stores OAuth tokens persistently. Requires an interactive terminal (TTY). Tokens are auto-refreshed when they expire (within 5 minutes of expiry). + +Credentials are stored in the system keyring (`qasphere-cli` service) when available, with fallback to `~/.config/qasphere/credentials.json` (mode `0600`). + +#### API Key + +Set `QAS_TOKEN` and `QAS_URL` via environment variables, `.env` file, or `.qaspherecli` file. + +### Credential Resolution Order Credentials are resolved in this order (first match wins): 1. **Environment variables** — `export QAS_TOKEN=... QAS_URL=...` 2. **`.env` file** — Standard dotenv file in the current working directory -3. **`.qaspherecli` file** — Searched from the current directory upward to filesystem root +3. **Keyring / credentials file** — OAuth tokens saved by `qasphere auth login` +4. **`.qaspherecli` file** — Searched from the current directory upward to filesystem root Both `.env` and `.qaspherecli` use the same `KEY=value` format: diff --git a/src/api/index.ts b/src/api/index.ts index fd04906..e28bbfc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,7 +14,8 @@ import { createTagApi } from './tags' import { createTCaseApi } from './tcases' import { createTestPlanApi } from './test-plans' import { createUserApi } from './users' -import { withBaseUrl, withDevAuth, withHeaders, withHttpRetry } from './utils' +import { withFetchMiddlewares, withBaseUrl, withAuth, withUserAgent, withHttpRetry } from './utils' +import type { AuthType } from './utils' import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { @@ -40,12 +41,13 @@ const getApi = (fetcher: typeof fetch) => { export type Api = ReturnType -export const createApi = (baseUrl: string, apiKey: string) => +export const createApi = (baseUrl: string, token: string, authType: AuthType = 'apikey') => getApi( - withHttpRetry( - withHeaders(withDevAuth(withBaseUrl(fetch, baseUrl)), { - Authorization: `ApiKey ${apiKey}`, - 'User-Agent': `qas-cli/${CLI_VERSION}`, - }) + withFetchMiddlewares( + fetch, + withBaseUrl(baseUrl), + withUserAgent(CLI_VERSION), + withAuth(token, authType), + withHttpRetry ) ) diff --git a/src/api/oauth.ts b/src/api/oauth.ts new file mode 100644 index 0000000..efe2e6a --- /dev/null +++ b/src/api/oauth.ts @@ -0,0 +1,143 @@ +import { + withFetchMiddlewares, + withBaseUrl, + withJson, + withUserAgent, + withHttpRetry, + jsonResponse, +} from './utils' +import { CLI_VERSION } from '../utils/version' +import { LOGIN_SERVICE_URL } from '../utils/config' + +const OAUTH_CLIENT_ID = 'qas-cli' +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' + +// --- Types --- + +export interface CheckTenantResponse { + redirectUrl: string + suspended: boolean +} + +export interface OAuthDeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number +} + +export interface OAuthTokenResponse { + access_token: string + token_type: string + expires_in: number + refresh_token: string +} + +export interface OAuthErrorResponse { + error: string + error_description?: string +} + +export type OAuthTokenResult = + | { ok: true; data: OAuthTokenResponse } + | { ok: false; error: OAuthErrorResponse } + +// --- Helpers --- + +const createFetcher = (baseUrl: string) => + withFetchMiddlewares( + fetch, + withBaseUrl(baseUrl), + withUserAgent(CLI_VERSION), + withJson, + withHttpRetry + ) + +async function oauthErrorResponse(response: Response): Promise { + try { + const json = (await response.json()) as OAuthErrorResponse + return { + error: json.error || 'unknown_error', + error_description: json.error_description || response.statusText, + } + } catch { + return { error: 'unknown_error', error_description: response.statusText } + } +} + +// --- API functions --- + +export async function checkTenant( + teamName: string +): Promise<{ tenantUrl: string; suspended: boolean }> { + const fetcher = createFetcher(LOGIN_SERVICE_URL) + const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { + method: 'GET', + }) + const data = await jsonResponse(response) + + // The check-tenant endpoint returns a redirect URL (e.g. http://tenant.localhost:5173/login). + // Extract just the origin for use as the API base URL. + const origin = new URL(data.redirectUrl).origin + return { tenantUrl: origin, suspended: data.suspended } +} + +export async function requestDeviceCode(tenantUrl: string): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/device/code', { + method: 'POST', + body: JSON.stringify({ client_id: OAUTH_CLIENT_ID }), + }) + + if (!response.ok) { + const err = await oauthErrorResponse(response) + throw new Error(err.error_description || err.error) + } + + return (await response.json()) as OAuthDeviceCodeResponse +} + +export async function pollDeviceToken( + tenantUrl: string, + deviceCode: string +): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/token', { + method: 'POST', + body: JSON.stringify({ + grant_type: DEVICE_CODE_GRANT_TYPE, + client_id: OAUTH_CLIENT_ID, + device_code: deviceCode, + }), + }) + + if (response.ok) { + return { ok: true, data: (await response.json()) as OAuthTokenResponse } + } + + const error = await oauthErrorResponse(response) + return { ok: false, error } +} + +export async function refreshAccessToken( + tenantUrl: string, + refreshToken: string +): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/token', { + method: 'POST', + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const err = await oauthErrorResponse(response) + throw new Error(err.error_description || err.error) + } + + return (await response.json()) as OAuthTokenResponse +} diff --git a/src/api/utils.ts b/src/api/utils.ts index a1c7849..024254b 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,14 +1,26 @@ -export const withBaseUrl = (fetcher: typeof fetch, baseUrl: string): typeof fetch => { - const normalizedBase = baseUrl.replace(/\/+$/, '') - return (input: URL | RequestInfo, init?: RequestInit | undefined) => { - if (typeof input === 'string') { - return fetcher(normalizedBase + input, init) +type FetchMiddleware = (fetcher: typeof fetch) => typeof fetch + +// TODO: Each middleware adds a frame to the stack trace. V8 defaults to 10 frames (Error.stackTraceLimit). +// With too many middlewares, the call site gets truncated from the stack, making it hard to identify where the request originated. +// Currently at ~4 middlewares which fits within the limit. +export const withFetchMiddlewares = ( + fetcher: typeof fetch, + ...middlewares: FetchMiddleware[] +): typeof fetch => middlewares.reduce((f, mw) => mw(f), fetcher) + +export const withBaseUrl = + (baseUrl: string): FetchMiddleware => + (fetcher: typeof fetch): typeof fetch => { + const normalized = baseUrl.replace(/\/+$/, '') + return (input: URL | RequestInfo, init?: RequestInit | undefined) => { + if (typeof input === 'string') { + return fetcher(normalized + input, init) + } + return fetcher(input, init) } - return fetcher(input, init) } -} -export const withJson = (fetcher: typeof fetch): typeof fetch => { +export const withJson: FetchMiddleware = (fetcher) => { const JSON_CONFIG: RequestInit = { headers: { Accept: 'application/json', @@ -42,35 +54,19 @@ export const withHeaders = ( } } -export const withDevAuth = (fetcher: typeof fetch): typeof fetch => { - const devAuth = process.env.QAS_DEV_AUTH - if (!devAuth) return fetcher +export const withUserAgent = + (version: string): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { 'User-Agent': `qas-cli/${version}` }) - return (input: URL | RequestInfo, init?: RequestInit | undefined) => { - const prev = (init?.headers as Record | undefined) ?? {} - const existing = prev['Cookie'] - const cookie = existing ? `${existing}; _devauth=${devAuth}` : `_devauth=${devAuth}` - return fetcher(input, { - ...init, - headers: { - ...prev, - Cookie: cookie, - }, - }) - } -} +export type AuthType = 'apikey' | 'bearer' -export const withApiKey = (fetcher: typeof fetch, apiKey: string): typeof fetch => { - return (input: URL | RequestInfo, init?: RequestInit | undefined) => { - return fetcher(input, { - ...init, - headers: { - Authorization: `ApiKey ${apiKey}`, - ...init?.headers, - }, +export const withAuth = + (token: string, authType: AuthType): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { + Authorization: authType === 'bearer' ? `Bearer ${token}` : `ApiKey ${token}`, }) - } -} export const jsonResponse = async (response: Response): Promise => { const json = await response.json() diff --git a/src/commands/api/executor.ts b/src/commands/api/executor.ts index f0ab487..a0715fb 100644 --- a/src/commands/api/executor.ts +++ b/src/commands/api/executor.ts @@ -1,5 +1,5 @@ import { existsSync, statSync } from 'node:fs' -import { loadEnvs } from '../../utils/env' +import { resolveAuth } from '../../utils/credentials' import { createApi } from '../../api/index' import { ArgumentValidationError, @@ -94,9 +94,9 @@ export async function executeCommand( const transformQuery = spec.transformQuery ?? kebabToCamelCaseKeys const query = transformQuery(rawQuery) - // 4. Connect to API (lazy env loading) - loadEnvs() - const api = createApi(process.env.QAS_URL!, process.env.QAS_TOKEN!) + // 4. Connect to API (lazy auth resolution) + const auth = await resolveAuth() + const api = createApi(auth.baseUrl, auth.token, auth.authType) // 5. Build argument map for error mapping const argumentMap: Record = {} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..0ea22cc --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,266 @@ +import type { Argv, CommandModule } from 'yargs' +import chalk from 'chalk' +import { ensureInteractive, prompt } from '../utils/prompt' +import { openBrowser } from '../utils/browser' +import { twirlLoader } from '../utils/misc' +import { + saveCredentials, + clearCredentials, + resolveCredentialSource, + resolvePersistedCredentialSource, + refreshIfNeeded, + type CredentialSource, +} from '../utils/credentials' +import { createApi } from '../api' +import { + checkTenant, + requestDeviceCode, + pollDeviceToken, + type OAuthDeviceCodeResponse, +} from '../api/oauth' + +async function resolveTenantUrl(): Promise { + const teamName = await prompt('Team name: ') + if (!teamName) { + console.error(chalk.red('Error:') + ' Team name is required.') + process.exit(1) + } + + try { + const { tenantUrl, suspended } = await checkTenant(teamName) + if (suspended) { + console.error(chalk.red('Error:') + ` Team "${teamName}" has been suspended.`) + process.exit(1) + } + return tenantUrl + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Could not find team "${teamName}": ${message}`) + process.exit(1) + } +} + +/** + * OAuth 2.0 Device Authorization Grant flow (RFC 8628). + * + * 1. CLI requests a device code and user code from the tenant backend. + * 2. CLI opens the browser to the verification URL (with pre-filled code). + * 3. The user approves the device in the browser. + * 4. CLI polls the token endpoint until the user approves or the code expires. + * 5. On approval, the backend returns access + refresh tokens which the CLI stores. + */ +async function handleDeviceLogin(): Promise { + const tenantUrl = await resolveTenantUrl() + + let deviceCodeResponse: OAuthDeviceCodeResponse + try { + deviceCodeResponse = await requestDeviceCode(tenantUrl) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Failed to start login flow: ${message}`) + process.exit(1) + } + + const { device_code, user_code, verification_uri, verification_uri_complete, expires_in } = + deviceCodeResponse + let currentInterval = deviceCodeResponse.interval + + console.log('Opening browser to authorize...') + const normalizedCode = user_code.replace('-', '') + const url = verification_uri_complete || `${verification_uri}?code=${normalizedCode}` + console.log(`\nIf the browser didn't open, visit:\n ${url}\n`) + openBrowser(url) + console.log(`Verify the code displayed in the browser: ${chalk.bold(normalizedCode)}\n`) + + const loader = twirlLoader() + loader.start('Waiting for authorization...') + + // Handle Ctrl+C gracefully during polling + const onSigint = () => { + loader.stop() + console.log('Cancelled.') + process.exit(0) + } + process.on('SIGINT', onSigint) + + const deadline = Date.now() + expires_in * 1000 + + try { + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000)) + + const result = await pollDeviceToken(tenantUrl, device_code) + + if (result.ok) { + loader.stop() + process.removeListener('SIGINT', onSigint) + + const source = await saveCredentials({ + type: 'oauth', + accessToken: result.data.access_token, + refreshToken: result.data.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + result.data.expires_in * 1000).toISOString(), + tenantUrl, + }) + + console.log(chalk.green('\u2713') + ` Logged in to ${tenantUrl}`) + console.log(` Credentials saved to ${source}.`) + return + } + + // Handle OAuth error responses + switch (result.error.error) { + case 'authorization_pending': + // Keep polling + break + case 'slow_down': + currentInterval += 5 + break + case 'access_denied': + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error(chalk.red('\u2717') + ' Authorization denied by user.') + process.exit(1) + break // unreachable, but satisfies linter + case 'expired_token': + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error(chalk.red('\u2717') + ' Authorization timed out. Please try again.') + process.exit(1) + break // unreachable + default: + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error( + chalk.red('Error:') + + ` Authorization failed: ${result.error.error_description || result.error.error}` + ) + process.exit(1) + } + } + + loader.stop() + console.error(chalk.red('\u2717') + ' Authorization timed out. Please try again.') + process.exit(1) + } catch (e) { + loader.stop() + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Authorization failed: ${message}`) + process.exit(1) + } +} + +async function handleStatus(): Promise { + let result = await resolveCredentialSource() + if (!result) { + console.log('Not logged in.') + return + } + + // Refresh OAuth tokens if expired before validating + if (result.authType === 'bearer') { + result = await refreshIfNeeded(result) + } + + const tenantUrl = result.authType === 'bearer' ? result.credentials.tenantUrl : result.tenantUrl + console.log(`Logged in to ${tenantUrl}`) + console.log(` Source: ${result.source}`) + + const token = result.authType === 'bearer' ? result.credentials.accessToken : result.token + try { + const api = createApi(tenantUrl, token, result.authType) + await api.projects.list() + console.log(` Status: ${chalk.green('valid')}`) + } catch { + console.log(` Status: ${chalk.red('invalid or expired')}`) + } + + if (result.authType === 'bearer') { + const expiresAt = new Date(result.credentials.accessTokenExpiresAt) + const remainingMs = expiresAt.getTime() - Date.now() + if (remainingMs > 0) { + const minutes = Math.floor(remainingMs / 60_000) + console.log(` Access token expires: in ${minutes} minute${minutes !== 1 ? 's' : ''}`) + } else { + console.log(` Access token expires: ${chalk.yellow('expired (will refresh on next use)')}`) + } + } +} + +const sourceLabels: Partial> = { + env_var: 'environment variables (QAS_TOKEN, QAS_URL)', + '.env': 'a .env file in the current directory', + '.qaspherecli': 'a .qaspherecli file', +} + +async function handleLogout(): Promise { + const clearableSource = await resolvePersistedCredentialSource() + + if (clearableSource) { + try { + await clearCredentials(clearableSource.source) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error( + chalk.red('Error:') + + ` Could not clear credentials from ${clearableSource.source}: ${message}` + ) + process.exit(1) + } + console.log('Logged out.') + + // Warn if credentials are still available from another source + const remaining = await resolveCredentialSource() + if (remaining) { + const label = sourceLabels[remaining.source] || remaining.source + console.log(`Note: credentials are still available via ${label}.`) + } + + console.log( + 'Note: your authorization is still active on the server. To revoke it, visit your QA Sphere account settings.' + ) + return + } + + // No clearable source — check if credentials come from a non-clearable source + const source = await resolveCredentialSource() + if (source) { + const label = sourceLabels[source.source] || source.source + console.log(`Cannot log out: credentials are provided via ${label}.`) + console.log('Remove them manually to log out.') + return + } + + console.log('Not logged in.') +} + +export const authCommand: CommandModule = { + command: 'auth', + describe: 'Manage authentication', + builder: (yargs: Argv) => + yargs + .command({ + command: 'login', + describe: 'Authenticate with QA Sphere', + handler: async () => { + ensureInteractive() + await handleDeviceLogin() + }, + }) + .command({ + command: 'status', + describe: 'Show current authentication status', + handler: async () => { + await handleStatus() + }, + }) + .command({ + command: 'logout', + describe: 'Clear stored credentials', + handler: async () => { + await handleLogout() + }, + }) + .demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/main.ts b/src/commands/main.ts index f4ac903..eee30e9 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,7 +1,8 @@ import yargs from 'yargs' import { ResultUploadCommandModule } from './resultUpload' +import { authCommand } from './auth' import { apiCommand } from './api/main' -import { qasEnvs, qasEnvFile } from '../utils/env' +import { qasEnvs, qasEnvFile } from '../utils/credentials' import { CLI_VERSION } from '../utils/version' export const run = (args: string | string[]) => @@ -9,9 +10,11 @@ export const run = (args: string | string[]) => .usage( `$0 [options] -Required variables: ${qasEnvs.join(', ')} +Authenticate using: $0 auth login +Or set variables: ${qasEnvs.join(', ')} These should be either exported as env vars or defined in a ${qasEnvFile} file.` ) + .command(authCommand) .command(new ResultUploadCommandModule('junit-upload')) .command(new ResultUploadCommandModule('playwright-json-upload')) .command(new ResultUploadCommandModule('allure-upload')) diff --git a/src/commands/resultUpload.ts b/src/commands/resultUpload.ts index 8b8fd53..9b341cf 100644 --- a/src/commands/resultUpload.ts +++ b/src/commands/resultUpload.ts @@ -1,6 +1,6 @@ import { Arguments, Argv, CommandModule } from 'yargs' import chalk from 'chalk' -import { loadEnvs, qasEnvFile } from '../utils/env' +import { resolveAuth } from '../utils/credentials' import { ResultUploadCommandArgs, ResultUploadCommandHandler, @@ -168,11 +168,6 @@ ${chalk.bold('Test Case Matching:')} '--create-tcases' )} to automatically create test cases in QA Sphere. -${chalk.bold('Required environment variables:')} - These should be either defined in a ${qasEnvFile} file or exported as environment variables: - - ${chalk.bold('QAS_TOKEN')}: Your QASphere API token - - ${chalk.bold('QAS_URL')}: Your QASphere instance URL (e.g., https://qas.eu1.qasphere.com) - ${chalk.bold('Run name template placeholders:')} - ${chalk.bold('{env:VAR_NAME}')}: Environment variables - ${chalk.bold('{YYYY}')}: 4-digit year @@ -190,8 +185,8 @@ ${chalk.bold('Run name template placeholders:')} } handler = async (args: Arguments) => { - loadEnvs() - const handler = new ResultUploadCommandHandler(this.type, args) + const auth = await resolveAuth() + const handler = new ResultUploadCommandHandler(this.type, args, auth) await handler.handle() } } diff --git a/src/tests/api/utils.spec.ts b/src/tests/api/utils.spec.ts index 64190a6..c3b9df9 100644 --- a/src/tests/api/utils.spec.ts +++ b/src/tests/api/utils.spec.ts @@ -4,21 +4,21 @@ import { withBaseUrl } from '../../api/utils' describe('withBaseUrl', () => { test('strips trailing slashes from base URL', async () => { const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) - const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com/') + const fetcher = withBaseUrl('https://host.com/')(mockFetcher as unknown as typeof fetch) await fetcher('/api/test') expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) }) test('strips multiple trailing slashes', async () => { const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) - const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com///') + const fetcher = withBaseUrl('https://host.com///')(mockFetcher as unknown as typeof fetch) await fetcher('/api/test') expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) }) test('works with base URL without trailing slash', async () => { const mockFetcher = vi.fn().mockResolvedValue(new Response('ok')) - const fetcher = withBaseUrl(mockFetcher as unknown as typeof fetch, 'https://host.com') + const fetcher = withBaseUrl('https://host.com')(mockFetcher as unknown as typeof fetch) await fetcher('/api/test') expect(mockFetcher).toHaveBeenCalledWith('https://host.com/api/test', undefined) }) diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts new file mode 100644 index 0000000..c967804 --- /dev/null +++ b/src/tests/auth-e2e.spec.ts @@ -0,0 +1,930 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +const loginServiceUrl = 'https://login.qasphere.com' +const tenantUrl = 'https://acme.eu1.qasphere.com' +const testApiKey = 'tenantId.keyId.keyToken' +const testAccessToken = 'tenantId.authId7chars.randomAccessToken' +const testRefreshToken = 'tenantId.authId7chars.randomRefreshToken' + +// --- MSW handlers --- + +const checkTenantHandler = http.get(`${loginServiceUrl}/api/check-tenant`, ({ request }) => { + const url = new URL(request.url) + const name = url.searchParams.get('name') + if (!name || name === 'nonexistent') { + return HttpResponse.json({ message: 'Tenant not found' }, { status: 404 }) + } + return HttpResponse.json({ redirectUrl: `${tenantUrl}/login`, suspended: false }) +}) + +const deviceCodeHandler = (interval = 0, expiresIn = 900) => + http.post(`${tenantUrl}/api/oauth/device/code`, () => { + return HttpResponse.json({ + device_code: 'long-random-device-code', + user_code: 'ABCD1234', + verification_uri: `${tenantUrl}/settings/oauth/device`, + verification_uri_complete: `${tenantUrl}/settings/oauth/device?code=ABCD1234`, + expires_in: expiresIn, + interval, + }) + }) + +const tokenSuccessHandler = (expiresIn = 3600) => + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json({ + access_token: testAccessToken, + token_type: 'Bearer', + expires_in: expiresIn, + refresh_token: testRefreshToken, + }) + }) + +const projectsHandler = http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `ApiKey ${testApiKey}` || auth === `Bearer ${testAccessToken}`) { + return HttpResponse.json({ data: [], total: 0 }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) +}) + +const server = setupServer(checkTenantHandler, projectsHandler) + +// --- Hoisted mock state --- +// vi.hoisted runs before imports, so vi.mock factories can reference these. +// Each test configures state before runCommand(); mock implementations read at call time. + +const mockState = vi.hoisted(() => ({ + teamName: 'acme', + keyringMode: 'unavailable' as 'unavailable' | 'available', + keyringStore: new Map(), + testHomeDir: '', +})) + +vi.mock('../utils/prompt', () => ({ + ensureInteractive: () => {}, + prompt: async () => mockState.teamName, +})) + +vi.mock('../utils/browser', () => ({ + openBrowser: () => {}, +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => mockState.testHomeDir } +}) + +vi.mock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + private key: string + constructor(service: string, account: string) { + this.key = `${service}:${account}` + } + setPassword(password: string) { + if (mockState.keyringMode !== 'available') { + throw new Error('Platform secure storage failure') + } + mockState.keyringStore.set(this.key, password) + } + getPassword(): string { + if (mockState.keyringMode !== 'available') { + throw new Error('Platform secure storage failure') + } + const val = mockState.keyringStore.get(this.key) + if (val === undefined) throw new Error('No entry') + return val + } + deletePassword() { + if (mockState.keyringMode !== 'available') { + throw new Error('Platform secure storage failure') + } + if (!mockState.keyringStore.has(this.key)) throw new Error('No entry') + mockState.keyringStore.delete(this.key) + } + }, +})) + +// --- Test setup --- + +let testHomeDir: string +let log: ReturnType +let err: ReturnType +const originalEnv = { ...process.env } + +function mockProcessExit() { + return vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) +} + +function credentialsFilePath() { + return join(testHomeDir, '.config', 'qasphere', 'credentials.json') +} + +function writeOAuthCredentials(source: 'file' | 'keyring') { + const creds = { + type: 'oauth', + accessToken: testAccessToken, + refreshToken: testRefreshToken, + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + tenantUrl, + } + if (source === 'keyring') { + mockState.keyringStore.set('qasphere-cli:credentials', JSON.stringify(creds)) + } else { + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), JSON.stringify(creds)) + } + return creds +} + +// vi.resetModules() is still needed so module-level constants (e.g. CONFIG_DIR = join(homedir(), ...)) +// re-evaluate with the current test's homedir. vi.mock (hoisted) ensures mocks always apply reliably. +async function runCommand(args: string) { + vi.resetModules() + const { run: freshRun } = await import('../commands/main') + return freshRun(args.split(' ')) +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +beforeEach(() => { + testHomeDir = join( + tmpdir(), + `qas-cli-auth-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}` + ) + mkdirSync(testHomeDir, { recursive: true }) + + mockState.testHomeDir = testHomeDir + mockState.keyringMode = 'unavailable' + mockState.keyringStore.clear() + mockState.teamName = 'acme' + + // Clear credential env vars so resolveCredentialSource() doesn't short-circuit + // to the env_var source, allowing tests to use test-isolated keyring/file/dotenv paths. + delete process.env.QAS_TOKEN + delete process.env.QAS_URL + delete process.env.QAS_LOGIN_SERVICE_URL + + log = vi.spyOn(console, 'log').mockImplementation(() => {}) + err = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + server.events.removeAllListeners() + + process.env.QAS_TOKEN = originalEnv.QAS_TOKEN + process.env.QAS_URL = originalEnv.QAS_URL + process.env.QAS_LOGIN_SERVICE_URL = originalEnv.QAS_LOGIN_SERVICE_URL + if (!originalEnv.QAS_TOKEN) delete process.env.QAS_TOKEN + if (!originalEnv.QAS_URL) delete process.env.QAS_URL + if (!originalEnv.QAS_LOGIN_SERVICE_URL) delete process.env.QAS_LOGIN_SERVICE_URL + + if (existsSync(testHomeDir)) { + rmSync(testHomeDir, { recursive: true }) + } + + vi.restoreAllMocks() +}) + +// --- Tests --- + +describe('auth login (device flow)', () => { + test('device flow login succeeds', async () => { + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('settings/oauth/device?code=')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + }) + + test('device flow saves OAuth credentials', async () => { + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + const credFile = credentialsFilePath() + expect(existsSync(credFile)).toBe(true) + const parsed = JSON.parse((await import('node:fs')).readFileSync(credFile, 'utf-8')) as Record< + string, + unknown + > + expect(parsed.type).toBe('oauth') + expect(parsed.accessToken).toBe(testAccessToken) + expect(parsed.refreshToken).toBe(testRefreshToken) + expect(parsed.tenantUrl).toBe(tenantUrl) + expect(typeof parsed.accessTokenExpiresAt).toBe('string') + }) + + test('device flow shows timeout on expiry', async () => { + const exit = mockProcessExit() + + // Use 0 interval and 0 expiresIn so the loop exits immediately + server.use( + deviceCodeHandler(0, 0), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'authorization_pending', error_description: 'user has not yet authorized' }, + { status: 400 } + ) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization timed out')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow handles expired_token from server', async () => { + const exit = mockProcessExit() + + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'expired_token', error_description: 'device code expired or invalid' }, + { status: 400 } + ) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization timed out')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow handles access_denied', async () => { + const exit = mockProcessExit() + + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'access_denied', error_description: 'user denied the request' }, + { status: 400 } + ) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization denied')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow handles slow_down by increasing interval', async () => { + let pollCount = 0 + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + pollCount++ + if (pollCount === 1) { + return HttpResponse.json( + { error: 'slow_down', error_description: 'polling too frequently' }, + { status: 400 } + ) + } + // Second poll succeeds + return HttpResponse.json({ + access_token: testAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: testRefreshToken, + }) + }) + ) + + await runCommand('auth login') + + expect(pollCount).toBe(2) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + }, 10_000) + + test('device flow handles device code request failure', async () => { + const exit = mockProcessExit() + + server.use( + http.post(`${tenantUrl}/api/oauth/device/code`, () => { + return HttpResponse.json( + { error: 'server_error', error_description: 'Internal server error' }, + { status: 500 } + ) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Failed to start login flow')) + expect(exit).toHaveBeenCalledWith(1) + }) +}) + +describe('auth login error cases', () => { + test('check-tenant returns 404 for unknown team', async () => { + mockState.teamName = 'nonexistent' + const exit = mockProcessExit() + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Could not find team')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('empty team name shows error', async () => { + mockState.teamName = '' + const exit = mockProcessExit() + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Team name is required')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('suspended team shows error', async () => { + const exit = mockProcessExit() + + server.use( + http.get(`${loginServiceUrl}/api/check-tenant`, () => { + return HttpResponse.json({ redirectUrl: `${tenantUrl}/login`, suspended: true }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('has been suspended')) + expect(exit).toHaveBeenCalledWith(1) + }) +}) + +describe('auth login → status → logout lifecycle', () => { + test('full lifecycle with device flow', async () => { + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + // Use an isolated directory so no .qaspherecli is found after logout + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + // Login + await runCommand('auth login') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + + // Status (valid) + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Access token expires')) + + // Logout + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(log).toHaveBeenCalledWith( + expect.stringContaining('authorization is still active on the server') + ) + + // Status after logout + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth status credential sources', () => { + test('shows env_var source when env vars are set', async () => { + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('env_var')) + }) + + test('shows .env source when .env file exists', async () => { + const envDir = join(testHomeDir, 'project') + mkdirSync(envDir, { recursive: true }) + writeFileSync(join(envDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + + const origCwd = process.cwd() + process.chdir(envDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) + } finally { + process.chdir(origCwd) + } + }) + + test('env vars take priority over .env file', async () => { + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + const envDir = join(testHomeDir, 'project') + mkdirSync(envDir, { recursive: true }) + writeFileSync( + join(envDir, '.env'), + 'QAS_TOKEN=other-token\nQAS_URL=https://other.qasphere.com\n' + ) + + const origCwd = process.cwd() + process.chdir(envDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('env_var')) + } finally { + process.chdir(origCwd) + } + }) + + test('shows .qaspherecli source when file exists in directory tree', async () => { + const projectDir = join(testHomeDir, 'project') + const subDir = join(projectDir, 'sub', 'dir') + mkdirSync(subDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(subDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('shows invalid status when credentials are bad', async () => { + process.env.QAS_TOKEN = 'bad-token' + process.env.QAS_URL = tenantUrl + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) + }) + + test('shows not logged in when no credentials found', async () => { + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test.each([ + { source: 'credentials.json' as const, setupKeyring: false }, + { source: 'keyring' as const, setupKeyring: true }, + ])('shows expiry for OAuth credentials from $source', async ({ source, setupKeyring }) => { + if (setupKeyring) mockState.keyringMode = 'available' + writeOAuthCredentials(setupKeyring ? 'keyring' : 'file') + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining(source)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Access token expires')) + }) +}) + +describe('auth logout edge cases', () => { + test('cannot log out when using env vars', async () => { + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + await runCommand('auth logout') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) + }) + + test('shows not logged in when nothing to clear', async () => { + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('second logout after file cleared shows not logged in', async () => { + writeOAuthCredentials('file') + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(existsSync(credentialsFilePath())).toBe(false) + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('logout mentions server-side revocation', async () => { + writeOAuthCredentials('file') + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith( + expect.stringContaining('authorization is still active on the server') + ) + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth logout source labels', () => { + test('cannot log out when using .env file', async () => { + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) + } finally { + process.chdir(origCwd) + } + }) + + test('cannot log out when using .qaspherecli file', async () => { + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('logout warns when env vars still active after clearing credentials', async () => { + writeOAuthCredentials('file') + + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(log).toHaveBeenCalledWith(expect.stringContaining('still available')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) + }) +}) + +describe('credential storage (keyring setPassword failure)', () => { + test('falls back to file when keyring setPassword throws', async () => { + // keyringMode stays 'unavailable' — setPassword throws, triggering file fallback + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(existsSync(credentialsFilePath())).toBe(true) + }) +}) + +describe('credential storage (keyring unavailable)', () => { + test('saves to file with 0600 permissions', async () => { + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + const credFile = credentialsFilePath() + expect(existsSync(credFile)).toBe(true) + expect(statSync(credFile).mode & 0o777).toBe(0o600) + }) + + test('overwrites existing credentials on re-login', async () => { + const secondAccessToken = 'tenantId.authId7chars.secondAccessToken' + const secondRefreshToken = 'tenantId.authId7chars.secondRefreshToken' + + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + // Re-login with different tokens + server.use( + deviceCodeHandler(), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json({ + access_token: secondAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: secondRefreshToken, + }) + }) + ) + + log.mockClear() + await runCommand('auth login') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + + // Verify file has the new token + const parsed = JSON.parse( + (await import('node:fs')).readFileSync(credentialsFilePath(), 'utf-8') + ) as Record + expect(parsed.accessToken).toBe(secondAccessToken) + }) +}) + +describe('credential storage (keyring available)', () => { + test('saves to keyring when available', async () => { + mockState.keyringMode = 'available' + + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + expect(mockState.keyringStore.size).toBe(0) + await runCommand('auth login') + + expect(mockState.keyringStore.size).toBe(1) + const value = Array.from(mockState.keyringStore.values())[0] + const parsed = JSON.parse(value) as Record + expect(parsed.type).toBe('oauth') + expect(parsed.accessToken).toBe(testAccessToken) + expect(parsed.tenantUrl).toBe(tenantUrl) + expect(log).toHaveBeenCalledWith(expect.stringContaining('keyring')) + expect(existsSync(credentialsFilePath())).toBe(false) + }) + + test('logout clears keyring entry', async () => { + mockState.keyringMode = 'available' + + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + expect(mockState.keyringStore.size).toBe(1) + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(mockState.keyringStore.size).toBe(0) + }) + + test('second logout after keyring cleared shows not logged in', async () => { + mockState.keyringMode = 'available' + + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth login') + expect(mockState.keyringStore.size).toBe(1) + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('auth status shows keyring as source', async () => { + mockState.keyringMode = 'available' + + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + expect(mockState.keyringStore.size).toBe(1) + + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('keyring')) + }) +}) + +describe('credential resolution edge cases', () => { + test('partial env vars (only QAS_TOKEN) falls through to .qaspherecli', async () => { + process.env.QAS_TOKEN = 'env-only-token' // Invalid token should fail assertions below if it were used + // QAS_URL intentionally not set — should not resolve as env_var + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth status') + expect(log).not.toHaveBeenCalledWith(expect.stringContaining('env_var')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + } finally { + process.chdir(origCwd) + } + }) + + test('corrupt credentials file warns and falls back gracefully', async () => { + // Write garbage to the credentials file + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), 'not valid json{{{') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not read credentials')) + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('credentials file with wrong shape warns and falls back gracefully', async () => { + // Write valid JSON but wrong shape (legacy apiKey format without type) + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), '{"apiKey": "test", "tenantUrl": "test"}') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid format')) + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('token refresh at load time', () => { + test('refreshes expired access token before running command', async () => { + // Write credentials with expired access token + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync( + join(credDir, 'credentials.json'), + JSON.stringify({ + type: 'oauth', + accessToken: 'expired-access-token', + refreshToken: testRefreshToken, + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), // expired 1 min ago + tenantUrl, + }) + ) + + const refreshedAccessToken = 'tenantId.authId7chars.refreshedAccessToken' + const refreshedRefreshToken = 'tenantId.authId7chars.refreshedRefreshToken' + + server.use( + http.post(`${tenantUrl}/api/oauth/token`, async ({ request }) => { + const body = (await request.json()) as Record + if (body.grant_type === 'refresh_token' && body.refresh_token === testRefreshToken) { + return HttpResponse.json({ + access_token: refreshedAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: refreshedRefreshToken, + }) + } + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'invalid refresh token' }, + { status: 401 } + ) + }), + http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `Bearer ${refreshedAccessToken}`) { + return HttpResponse.json({ data: [], total: 0 }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }) + ) + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + + // Verify credentials file was updated with new tokens + const parsed = JSON.parse( + (await import('node:fs')).readFileSync(credentialsFilePath(), 'utf-8') + ) as Record + expect(parsed.accessToken).toBe(refreshedAccessToken) + expect(parsed.refreshToken).toBe(refreshedRefreshToken) + }) + + test('expired refresh token shows session expired message', async () => { + const exit = mockProcessExit() + + // Write credentials with expired access token and a refresh token that will fail + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync( + join(credDir, 'credentials.json'), + JSON.stringify({ + type: 'oauth', + accessToken: 'expired-access-token', + refreshToken: 'expired-refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), + tenantUrl, + }) + ) + + server.use( + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'refresh token expired' }, + { status: 401 } + ) + }) + ) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(emptyDir) + + try { + await runCommand('auth status').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Session expired')) + expect(err).toHaveBeenCalledWith(expect.stringContaining('qasphere auth login')) + expect(exit).toHaveBeenCalledWith(1) + } finally { + process.chdir(origCwd) + } + }) +}) diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 0000000..db1ea0e --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,21 @@ +import { execFile } from 'node:child_process' + +const onError = (err: Error | null) => { + if (err) console.error('Could not open browser. Please visit the URL manually.') +} + +export function openBrowser(url: string): void { + new URL(url) // Validate URL to prevent shell injection + + switch (process.platform) { + case 'darwin': + execFile('open', [url], onError) + break + case 'win32': + execFile('cmd', ['/c', 'start', '', url], onError) + break + default: + execFile('xdg-open', [url], onError) + break + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index a6ba1e2..fdefd17 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1 +1,2 @@ export const REQUIRED_NODE_VERSION = '18.0.0' +export const LOGIN_SERVICE_URL = process.env.QAS_LOGIN_SERVICE_URL || 'https://login.qasphere.com' diff --git a/src/utils/credentials/index.ts b/src/utils/credentials/index.ts new file mode 100644 index 0000000..40dca53 --- /dev/null +++ b/src/utils/credentials/index.ts @@ -0,0 +1,10 @@ +export type { OAuthCredentials, CredentialSource, AuthConfig } from './types' +export { saveCredentials, clearCredentials } from './storage' +export { + qasEnvFile, + qasEnvs, + resolveCredentialSource, + resolvePersistedCredentialSource, + refreshIfNeeded, + resolveAuth, +} from './resolvers' diff --git a/src/utils/credentials/keyring.ts b/src/utils/credentials/keyring.ts new file mode 100644 index 0000000..ed58eee --- /dev/null +++ b/src/utils/credentials/keyring.ts @@ -0,0 +1,34 @@ +const KEYRING_SERVICE = 'qasphere-cli' +const KEYRING_ACCOUNT = 'credentials' + +export interface KeyringEntry { + setPassword(password: string): void + getPassword(): string + deletePassword(): void +} + +type KeyringModule = { + Entry: new (service: string, account: string) => KeyringEntry +} + +async function loadKeyringModule(): Promise { + try { + return (await import('@napi-rs/keyring')) as KeyringModule + } catch { + // Import fails when the native binary is missing (e.g., Alpine/musl where + // the platform-specific @napi-rs/keyring-* package is not installed). + return null + } +} + +export async function getKeyringEntry(): Promise { + const mod = await loadKeyringModule() + if (!mod) return null + try { + return new mod.Entry(KEYRING_SERVICE, KEYRING_ACCOUNT) + } catch { + // Entry construction fails when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} diff --git a/src/utils/credentials/resolvers.ts b/src/utils/credentials/resolvers.ts new file mode 100644 index 0000000..b27c8fa --- /dev/null +++ b/src/utils/credentials/resolvers.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { config } from 'dotenv' +import type { DotenvPopulateInput } from 'dotenv' +import chalk from 'chalk' +import { refreshAccessToken } from '../../api/oauth' +import { + saveCredentials, + clearCredentials, + loadCredentialsFromKeyring, + loadCredentialsFromFile, +} from './storage' +import type { + OAuthCredentials, + ApiKeyResolved, + OAuthResolved, + ResolvedCredentials, + AuthConfig, +} from './types' + +export const qasEnvFile = '.qaspherecli' +export const qasEnvs = ['QAS_TOKEN', 'QAS_URL'] + +const REFRESH_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes + +function resolveFromEnvVars(): ApiKeyResolved | null { + if (process.env.QAS_TOKEN && process.env.QAS_URL) { + return { + token: process.env.QAS_TOKEN, + tenantUrl: process.env.QAS_URL, + authType: 'apikey', + source: 'env_var', + } + } + return null +} + +function resolveFromDotenv(): ApiKeyResolved | null { + const dotenvPath = join(process.cwd(), '.env') + if (!existsSync(dotenvPath)) return null + + const fileEnvs: DotenvPopulateInput = {} + config({ path: dotenvPath, processEnv: fileEnvs }) + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + token: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + authType: 'apikey', + source: '.env', + } + } + return null +} + +function resolveFromQaspherecli(): ApiKeyResolved | null { + let dir = process.cwd() + for (;;) { + const envPath = join(dir, qasEnvFile) + if (existsSync(envPath)) { + const fileEnvs: DotenvPopulateInput = {} + config({ path: envPath, processEnv: fileEnvs }) + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + token: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + authType: 'apikey', + source: '.qaspherecli', + } + } + break + } + + const parentDir = dirname(dir) + if (parentDir === dir) break + dir = parentDir + } + return null +} + +export async function resolvePersistedCredentialSource(): Promise { + const keyringCreds = await loadCredentialsFromKeyring() + if (keyringCreds) { + return { credentials: keyringCreds, authType: 'bearer', source: 'keyring' } + } + + const fileCreds = loadCredentialsFromFile() + if (fileCreds) { + return { credentials: fileCreds, authType: 'bearer', source: 'credentials.json' } + } + return null +} + +/** + * Resolves the credential source without modifying process.env. + * Used by auth status/logout to report where credentials come from. + */ +export async function resolveCredentialSource(): Promise { + // 1. Environment variables + const envResult = resolveFromEnvVars() + if (envResult) return envResult + + // 2. .env file in cwd + const dotenvResult = resolveFromDotenv() + if (dotenvResult) return dotenvResult + + // 3. Keyring or credentials.json (OAuth only) + const persisted = await resolvePersistedCredentialSource() + if (persisted) return persisted + + // 4. .qaspherecli file + return resolveFromQaspherecli() +} + +export async function refreshIfNeeded(resolved: OAuthResolved): Promise { + const expiresAt = new Date(resolved.credentials.accessTokenExpiresAt).getTime() + if (expiresAt - Date.now() >= REFRESH_THRESHOLD_MS) { + return resolved + } + + try { + const tokenResponse = await refreshAccessToken( + resolved.credentials.tenantUrl, + resolved.credentials.refreshToken + ) + + const updated: OAuthCredentials = { + type: 'oauth', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), + tenantUrl: resolved.credentials.tenantUrl, + } + + const newSource = await saveCredentials(updated) + return { credentials: updated, authType: 'bearer', source: newSource } + } catch { + // Refresh failed — clear stale credentials and tell user to re-login + try { + await clearCredentials(resolved.source) + } catch { + // Ignore clear errors + } + + console.error(chalk.red('Session expired.') + ' Please log in again:') + console.error(chalk.green(' qasphere auth login')) + process.exit(1) + } +} + +export async function resolveAuth(): Promise { + const resolved = await resolveCredentialSource() + if (!resolved) { + console.log( + chalk.red('Missing required environment variables: ') + + qasEnvs.filter((k) => !process.env[k]).join(', ') + ) + console.log('\nYou can authenticate using:') + console.log(chalk.green(' qasphere auth login')) + console.log('\nOr create a .qaspherecli file with the following content:') + console.log( + chalk.green(` +QAS_TOKEN=your_token +QAS_URL=http://your-qasphere-instance-url + +# Example: +# QAS_TOKEN=tst0000001.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest +# QAS_URL=https://tenant_id.eu1.qasphere.com`) + ) + console.log('\nOr export them as environment variables:') + console.log( + chalk.green(` +export QAS_TOKEN=tst0000001.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest +export QAS_URL=https://tenant_id.eu1.qasphere.com`) + ) + process.exit(1) + } + + if (resolved.authType === 'bearer') { + const refreshed = await refreshIfNeeded(resolved) + return { + token: refreshed.credentials.accessToken, + baseUrl: refreshed.credentials.tenantUrl, + authType: 'bearer', + } + } + + return { + token: resolved.token, + baseUrl: resolved.tenantUrl, + authType: 'apikey', + } +} diff --git a/src/utils/credentials/storage.ts b/src/utils/credentials/storage.ts new file mode 100644 index 0000000..d6fe1d7 --- /dev/null +++ b/src/utils/credentials/storage.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { getKeyringEntry } from './keyring' +import { oauthCredentialsSchema, type OAuthCredentials, type CredentialSource } from './types' + +const CONFIG_DIR = join(homedir(), '.config', 'qasphere') +const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json') + +export async function saveCredentials(credentials: OAuthCredentials): Promise { + const json = JSON.stringify(credentials) + + const entry = await getKeyringEntry() + if (entry) { + try { + entry.setPassword(json) + return 'keyring' + } catch { + console.warn('Warning: system keyring is not available, saving credentials to file instead.') + } + } + + // Fallback: write to file with restricted permissions + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) + writeFileSync(CREDENTIALS_FILE, json, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(CREDENTIALS_FILE, 0o600) // belt-and-suspenders for existing files + return 'credentials.json' +} + +function parseOAuthCredentials(obj: unknown): OAuthCredentials | null { + const result = oauthCredentialsSchema.safeParse(obj) + return result.success ? result.data : null +} + +export async function loadCredentialsFromKeyring(): Promise { + const entry = await getKeyringEntry() + if (!entry) return null + + try { + const json = entry.getPassword() + return parseOAuthCredentials(JSON.parse(json)) + } catch { + // Operations fail when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} + +export function loadCredentialsFromFile(): OAuthCredentials | null { + if (!existsSync(CREDENTIALS_FILE)) return null + + try { + const json = readFileSync(CREDENTIALS_FILE, 'utf-8') + const creds = parseOAuthCredentials(JSON.parse(json)) + if (!creds) { + console.warn(`Warning: credentials file at ${CREDENTIALS_FILE} has invalid format.`) + return null + } + return creds + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.warn(`Warning: could not read credentials file at ${CREDENTIALS_FILE}: ${message}`) + return null + } +} + +export async function clearCredentials(source: CredentialSource): Promise { + if (source === 'keyring') { + const entry = await getKeyringEntry() + if (!entry) throw new Error('Keyring is not available') + entry.deletePassword() + return + } else if (source === 'credentials.json') { + unlinkSync(CREDENTIALS_FILE) + return + } + throw new Error(`Cannot clear credentials from ${source}`) +} diff --git a/src/utils/credentials/types.ts b/src/utils/credentials/types.ts new file mode 100644 index 0000000..c2301ab --- /dev/null +++ b/src/utils/credentials/types.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import type { AuthType } from '../../api/utils' + +export const oauthCredentialsSchema = z.object({ + type: z.literal('oauth'), + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + accessTokenExpiresAt: z.string().datetime(), // ISO 8601 + tenantUrl: z.string().min(1), +}) + +export type OAuthCredentials = z.infer + +export type CredentialSource = 'env_var' | '.env' | 'keyring' | 'credentials.json' | '.qaspherecli' + +export interface ApiKeyResolved { + token: string + tenantUrl: string + authType: 'apikey' + source: CredentialSource +} + +export interface OAuthResolved { + credentials: OAuthCredentials + authType: 'bearer' + source: CredentialSource +} + +export type ResolvedCredentials = ApiKeyResolved | OAuthResolved + +export interface AuthConfig { + token: string + baseUrl: string + authType: AuthType +} diff --git a/src/utils/env.ts b/src/utils/env.ts deleted file mode 100644 index 3410477..0000000 --- a/src/utils/env.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { config, DotenvPopulateInput } from 'dotenv' -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import chalk from 'chalk' - -export const qasEnvFile = '.qaspherecli' -export const qasEnvs = ['QAS_TOKEN', 'QAS_URL'] - -export function hasRequiredKeys(env: NodeJS.ProcessEnv | DotenvPopulateInput): boolean { - return qasEnvs.every((key) => key in env && env[key] !== 'undefined') -} - -export function loadEnvs(): void { - if (hasRequiredKeys(process.env)) { - return - } - - const fileEnvs: DotenvPopulateInput = {} - let dir = process.cwd() - - const dotenvPath = join(process.cwd(), '.env') - if (existsSync(dotenvPath)) { - config({ path: dotenvPath, processEnv: fileEnvs }) - } - - if (!hasRequiredKeys(fileEnvs)) { - for (;;) { - const envPath = join(dir, qasEnvFile) - if (existsSync(envPath)) { - config({ path: envPath, processEnv: fileEnvs }) - break - } - - const parentDir = dirname(dir) - if (parentDir === dir) { - // If the parent directory is the same as the current, we've reached the root - break - } - - dir = parentDir - } - } - - const missingEnvs = [] - for (const env of qasEnvs) { - if (!(env in process.env)) { - const fileEnvValue = fileEnvs[env] - if (fileEnvValue && fileEnvValue !== 'undefined') { - process.env[env] = fileEnvValue - } else { - missingEnvs.push(env) - } - } - } - - if (missingEnvs.length == 0) { - return - } - - console.log(chalk.red('Missing required environment variables: ') + missingEnvs.join(', ')) - console.log('\nPlease create a .qaspherecli file with the following content:') - console.log( - chalk.green(` -QAS_TOKEN=your_token -QAS_URL=http://your-qasphere-instance-url - -# Example: -# QAS_TOKEN=tst0000001.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest -# QAS_URL=http://tenant1.localhost:5173`) - ) - console.log('\nOr export them as environment variables:') - console.log( - chalk.green(` -export QAS_TOKEN=your_token -export QAS_URL=http://your-qasphere-instance-url`) - ) - process.exit(1) -} diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000..b1c0bf6 --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,25 @@ +import { createInterface } from 'node:readline' + +export function ensureInteractive(): void { + if (!process.stdin.isTTY) { + console.error( + 'Error: This command requires an interactive terminal.\n' + + 'Use environment variables (QAS_TOKEN, QAS_URL) for non-interactive environments.' + ) + process.exit(1) + } +} + +export function prompt(question: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) +} diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 1d9c402..8c58f3e 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { MarkerParser } from './MarkerParser' import { Api, createApi } from '../../api' +import type { AuthConfig } from '../credentials' import { TCase } from '../../api/tcases' import { ParseResult, TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' @@ -80,12 +81,11 @@ export class ResultUploadCommandHandler { constructor( private type: UploadCommandType, - private args: Arguments + private args: Arguments, + private auth: AuthConfig ) { - const apiToken = process.env.QAS_TOKEN! - - this.baseUrl = process.env.QAS_URL!.replace(/\/+$/, '') - this.api = createApi(this.baseUrl, apiToken) + this.baseUrl = auth.baseUrl + this.api = createApi(this.baseUrl, auth.token, auth.authType) this.markerParser = new MarkerParser(this.type) } @@ -446,7 +446,12 @@ export class ResultUploadCommandHandler { runFailureLogs: string }) { const runUrl = `${this.baseUrl}/project/${projectCode}/run/${runId}` - const uploader = new ResultUploader(this.markerParser, this.type, { ...this.args, runUrl }) + const uploader = new ResultUploader( + this.markerParser, + this.type, + { ...this.args, runUrl }, + this.auth + ) await uploader.handle(results, runFailureLogs) } } diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index e32a36b..96b3523 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -4,6 +4,7 @@ import escapeHtml from 'escape-html' import { RunTCase } from '../../api/runs' import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' +import type { AuthConfig } from '../credentials' import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' import type { MarkerParser } from './MarkerParser' @@ -21,14 +22,14 @@ export class ResultUploader { constructor( private markerParser: MarkerParser, private type: UploadCommandType, - private args: Arguments + private args: Arguments, + auth: AuthConfig ) { - const apiToken = process.env.QAS_TOKEN! const { url, project, run } = parseRunUrl(args) this.project = project this.run = run - this.api = createApi(url, apiToken) + this.api = createApi(url, auth.token, auth.authType) } async handle(results: TestCaseResult[], runFailureLogs?: string) {