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 6fbfc85..e2854b5 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,31 +78,52 @@ 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: +### OAuth (recommended) -- `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`) +The recommended way to authenticate is using the interactive login command: + +```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. + +OAuth sessions are valid for 90 days. The 90-day window resets every time the CLI is used — as long as you keep running `qasphere` commands, you won't need to re-authenticate. `qasphere auth status` shows the current remaining time. + +Other auth commands: + +```bash +qasphere auth status # Show current authentication status +qasphere auth logout # Clear stored credentials +``` + +### API Key -These variables could be defined: +Instead of using `auth login`, you can manually set the required variables: -- as environment variables -- in .env of a current working directory -- in a special `.qaspherecli` configuration file in your project directory (or any parent directory) +- `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`) -Example: .qaspherecli +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 ``` +### Credential resolution order + +OAuth (`auth login`) is the recommended source. 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 + ## Command: `api` The `api` command provides direct access to the QA Sphere public API from the command line. Outputting JSON to stdout for easy scripting and piping. @@ -369,7 +395,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 4145b10..fdf52f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.7.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 58f2ed5..580bc05 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..cde2204 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,7 +14,15 @@ 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, + withDevAuth, + withUserAgent, + withHttpRetry, +} from './utils' +import type { AuthType } from './utils' import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { @@ -40,12 +48,14 @@ 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), + withDevAuth, + 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..bfd06d2 --- /dev/null +++ b/src/api/oauth.ts @@ -0,0 +1,202 @@ +import { z } from 'zod' +import { + withFetchMiddlewares, + withBaseUrl, + withJson, + withUserAgent, + withHttpRetry, + jsonResponse, + DEFAULT_HTTP_RETRY_OPTIONS, + parseRetryAfterMs, +} 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' +const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// --- Schemas & types --- + +export const CheckTenantResponseSchema = z.object({ + redirectUrl: z.string().url(), + suspended: z.boolean(), +}) +export type CheckTenantResponse = z.infer + +export const OAuthDeviceCodeResponseSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string().url(), + verification_uri_complete: z.string().url(), + expires_in: z.number(), + interval: z.number(), +}) +export type OAuthDeviceCodeResponse = z.infer + +export const OAuthTokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + refresh_token: z.string(), + refresh_token_expires_in: z.number(), +}) +export type OAuthTokenResponse = z.infer + +export const OAuthErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}) +export type OAuthErrorResponse = z.infer + +export type OAuthTokenResult = + | { ok: true; data: OAuthTokenResponse } + | { ok: false; error: OAuthErrorResponse; retryAfterMs?: number } + +export class OAuthProtocolError extends Error { + readonly code: string + readonly description?: string + + constructor(error: OAuthErrorResponse) { + super(error.error_description || error.error) + this.name = 'OAuthProtocolError' + this.code = error.error + this.description = error.error_description + } +} + +function parseOAuthResponse(schema: z.ZodType, payload: unknown, context: string): T { + const result = schema.safeParse(payload) + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join('.') || ''}: ${i.message}`) + .join('; ') + throw new Error(`Invalid ${context} response from OAuth server: ${issues}`) + } + return result.data +} + +// --- Helpers --- + +const createFetcher = (baseUrl: string, opts: { retry?: boolean } = {}) => { + const middlewares = [withBaseUrl(baseUrl), withUserAgent(CLI_VERSION), withJson] + if (opts.retry !== false) middlewares.push(withHttpRetry) + return withFetchMiddlewares(fetch, ...middlewares) +} + +async function oauthErrorResponse(response: Response): Promise { + try { + const json = await response.json() + const parsed = OAuthErrorResponseSchema.safeParse(json) + if (parsed.success) { + return { + error: parsed.data.error || 'unknown_error', + error_description: parsed.data.error_description || response.statusText, + } + } + return { error: 'unknown_error', 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 raw = await jsonResponse(response) + const data = parseOAuthResponse(CheckTenantResponseSchema, raw, 'check-tenant') + + // 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 parseOAuthResponse(OAuthDeviceCodeResponseSchema, await response.json(), 'device-code') +} + +export async function pollDeviceToken( + tenantUrl: string, + deviceCode: string +): Promise { + // Retries are intentionally disabled here — the caller already polls on a fixed + // interval, so withHttpRetry would silently consume the polling deadline. + const fetcher = createFetcher(tenantUrl, { retry: false }) + 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: parseOAuthResponse(OAuthTokenResponseSchema, await response.json(), 'token'), + } + } + + // Treat the same statuses as withHttpRetry would (429, 502, 503) as transient, + // so the polling loop can keep going instead of exiting on a single hiccup. + if (DEFAULT_HTTP_RETRY_OPTIONS.retryableStatuses.has(response.status)) { + return { + ok: false, + error: { + error: 'transient', + error_description: `HTTP ${response.status} ${response.statusText}`, + }, + retryAfterMs: parseRetryAfterMs(response.headers.get('Retry-After')), + } + } + + 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_GRANT_TYPE, + client_id: OAUTH_CLIENT_ID, + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const err = await oauthErrorResponse(response) + // 4xx with a recognized OAuth error code is a protocol-level rejection + // (refresh token revoked, invalid client, etc.). Anything else (5xx, + // unrecognized 4xx) is treated as transport so callers can preserve credentials. + if (response.status >= 400 && response.status < 500 && err.error !== 'unknown_error') { + throw new OAuthProtocolError(err) + } + throw new Error(err.error_description || err.error || response.statusText) + } + + return parseOAuthResponse(OAuthTokenResponseSchema, await response.json(), 'refresh-token') +} diff --git a/src/api/users.ts b/src/api/users.ts index 2b34808..dc19c1d 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -7,6 +7,14 @@ export interface User { role: string } +export interface MeUser { + id: number + email: string + name: string + avatar: string | null + role: string +} + export const createUserApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { @@ -14,5 +22,9 @@ export const createUserApi = (fetcher: typeof fetch) => { fetcher(`/api/public/v0/users`) .then((r) => jsonResponse<{ users: User[] }>(r)) .then((r) => r.users), + me: () => + fetcher(`/api/public/v0/users/me`) + .then((r) => jsonResponse<{ user: MeUser }>(r)) + .then((r) => r.user), } } diff --git a/src/api/utils.ts b/src/api/utils.ts index a1c7849..d87ce01 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,7 +54,21 @@ export const withHeaders = ( } } -export const withDevAuth = (fetcher: typeof fetch): typeof fetch => { +export const withUserAgent = + (version: string): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { 'User-Agent': `qas-cli/${version}` }) + +export type AuthType = 'apikey' | 'bearer' + +export const withAuth = + (token: string, authType: AuthType): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { + Authorization: authType === 'bearer' ? `Bearer ${token}` : `ApiKey ${token}`, + }) + +export const withDevAuth: FetchMiddleware = (fetcher) => { const devAuth = process.env.QAS_DEV_AUTH if (!devAuth) return fetcher @@ -60,18 +86,6 @@ export const withDevAuth = (fetcher: typeof fetch): typeof fetch => { } } -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 jsonResponse = async (response: Response): Promise => { const json = await response.json() if (response.ok) { @@ -122,7 +136,7 @@ interface HttpRetryOptions { retryableStatuses: Set } -const DEFAULT_HTTP_RETRY_OPTIONS: HttpRetryOptions = { +export const DEFAULT_HTTP_RETRY_OPTIONS: HttpRetryOptions = { maxRetries: 5, baseDelayMs: 1000, backoffFactor: 2, @@ -130,6 +144,16 @@ const DEFAULT_HTTP_RETRY_OPTIONS: HttpRetryOptions = { retryableStatuses: new Set([429, 502, 503]), } +// RFC 7231 §7.1.3 — `Retry-After` is either delta-seconds or an HTTP-date. +// Returns undefined when the header is absent or unparseable. +export const parseRetryAfterMs = (header: string | null): number | undefined => { + if (header === null) return undefined + const seconds = Number(header) + if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1000) + const date = Date.parse(header) + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()) +} + export const withHttpRetry = ( fetcher: typeof fetch, options?: Partial @@ -149,20 +173,8 @@ export const withHttpRetry = ( break } - const retryAfter = lastResponse.headers.get('Retry-After') - let delayMs: number - - if (retryAfter !== null) { - const parsed = Number(retryAfter) - if (!Number.isNaN(parsed)) { - delayMs = parsed * 1000 - } else { - const date = Date.parse(retryAfter) - delayMs = Number.isNaN(date) ? opts.baseDelayMs : Math.max(0, date - Date.now()) - } - } else { - delayMs = opts.baseDelayMs * Math.pow(opts.backoffFactor, attempt) - } + const retryAfterMs = parseRetryAfterMs(lastResponse.headers.get('Retry-After')) + const delayMs = retryAfterMs ?? opts.baseDelayMs * Math.pow(opts.backoffFactor, attempt) const jitter = delayMs * opts.jitterFraction * Math.random() await new Promise((resolve) => setTimeout(resolve, delayMs + jitter)) 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..4607cc8 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,269 @@ +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, + credentialsFromTokenResponse, + 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 url = verification_uri_complete || `${verification_uri}?code=${user_code}` + 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(user_code)}\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(credentialsFromTokenResponse(result.data, 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 'transient': { + // 429 / 5xx — keep polling, honor Retry-After if larger than current interval + if (result.retryAfterMs !== undefined) { + const seconds = Math.ceil(result.retryAfterMs / 1000) + if (seconds > currentInterval) currentInterval = seconds + } + break + } + case 'access_denied': + loader.stop() + console.error(chalk.red('\u2717') + ' Authorization denied by user.') + process.exit(1) + break // unreachable, but satisfies linter + case 'expired_token': + loader.stop() + console.error(chalk.red('\u2717') + ' Authorization timed out. Please try again.') + process.exit(1) + break // unreachable + default: + loader.stop() + 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(`Credentials connected via ${tenantUrl}`) + console.log(` Source: ${result.source}`) + + const token = result.authType === 'bearer' ? result.credentials.accessToken : result.token + let valid = false + try { + const api = createApi(tenantUrl, token, result.authType) + const me = await api.users.me() + console.log(` Status: ${chalk.green('valid')}`) + console.log(` User: ${me.name} <${me.email}> (${me.role})`) + valid = true + } catch { + console.log(` Status: ${chalk.red('invalid or expired')}`) + } + + if (valid && result.authType === 'bearer') { + const expiresAt = new Date(result.credentials.refreshTokenExpiresAt) + const remainingMs = expiresAt.getTime() - Date.now() + const days = Math.max(0, Math.floor(remainingMs / (24 * 60 * 60 * 1000))) + console.log( + ` Re-authentication required: in ${days} day${days !== 1 ? 's' : ''} (resets on each use)` + ) + } +} + +const sourceLabels: Partial> = { + env_var: 'environment variables (QAS_TOKEN, QAS_URL)', + '.env': 'a .env file in the current directory', + '.qaspherecli': 'a .qaspherecli file', + keyring: 'the system keyring', + 'credentials.json': 'a credentials file (~/.config/qasphere/credentials.json)', +} + +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, go to Settings > API Keys > OAuth Authorizations in QA Sphere.' + ) + 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.') + 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 currently active credential across all sources and verify the server is reachable', + 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 92b4eeb..977e3d4 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, @@ -169,11 +169,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 @@ -191,8 +186,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..a1495d6 --- /dev/null +++ b/src/tests/auth-e2e.spec.ts @@ -0,0 +1,1333 @@ +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, refreshExpiresIn = 90 * 24 * 3600) => + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json({ + access_token: testAccessToken, + token_type: 'Bearer', + expires_in: expiresIn, + refresh_token: testRefreshToken, + refresh_token_expires_in: refreshExpiresIn, + }) + }) + +const testMeUser = { + id: 42, + email: 'tester@example.com', + name: 'Test User', + avatar: null, + role: 'admin', +} + +const meHandler = http.get(`${tenantUrl}/api/public/v0/users/me`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `ApiKey ${testApiKey}` || auth === `Bearer ${testAccessToken}`) { + return HttpResponse.json({ user: testMeUser }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) +}) + +const server = setupServer(checkTenantHandler, meHandler) + +// --- 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(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 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 leaves no SIGINT listener on success', async () => { + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + const baseline = process.listenerCount('SIGINT') + await runCommand('auth login') + expect(process.listenerCount('SIGINT')).toBe(baseline) + }) + + 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, + refresh_token_expires_in: 90 * 24 * 3600, + }) + }) + ) + + await runCommand('auth login') + + expect(pollCount).toBe(2) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + }, 10_000) + + test('device flow rejects malformed token response payload', async () => { + const exit = mockProcessExit() + + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + // Missing access_token / refresh_token / expires_in + return HttpResponse.json({ token_type: 'Bearer' }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Invalid token response from OAuth server') + ) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow rejects malformed verification URI in device-code response', async () => { + const exit = mockProcessExit() + + server.use( + http.post(`${tenantUrl}/api/oauth/device/code`, () => { + return HttpResponse.json({ + device_code: 'long-random-device-code', + user_code: 'ABCD1234', + verification_uri: 'not a url', + verification_uri_complete: 'still not a url', + expires_in: 900, + interval: 0, + }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Invalid device-code response from OAuth server') + ) + expect(err).toHaveBeenCalledWith(expect.stringContaining('verification_uri')) + expect(exit).toHaveBeenCalledWith(1) + }) + + 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(`Credentials connected via ${tenantUrl}`) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + `User: ${testMeUser.name} <${testMeUser.email}> (${testMeUser.role})` + ) + ) + expect(log).toHaveBeenCalledWith( + expect.stringMatching(/Re-authentication required: in (89|90) days \(resets on each use\)/) + ) + + // 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(`Credentials connected via ${tenantUrl}`) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('env_var')) + }) + + test('strips trailing slash from QAS_URL env var', async () => { + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = `${tenantUrl}/` + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith( + expect.stringContaining(`Credentials connected via ${tenantUrl}`) + ) + expect(log).not.toHaveBeenCalledWith(expect.stringContaining(`${tenantUrl}/\n`)) + }) + + test('strips trailing slash from .env QAS_URL', 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(`Credentials connected via ${tenantUrl}`) + ) + } finally { + process.chdir(origCwd) + } + }) + + test('strips trailing slash from .qaspherecli QAS_URL', 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 status') + expect(log).toHaveBeenCalledWith( + expect.stringContaining(`Credentials connected via ${tenantUrl}`) + ) + } finally { + process.chdir(origCwd) + } + }) + + 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(`Credentials connected via ${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(`Credentials connected via ${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(`Credentials connected via ${tenantUrl}`) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining(source)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Re-authentication required')) + }) +}) + +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, + refresh_token_expires_in: 90 * 24 * 3600, + }) + }) + ) + + 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 () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + 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(`Credentials connected via ${tenantUrl}`) + ) + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/Environment variables.*QAS_URL is missing/) + ) + } finally { + process.chdir(origCwd) + } + }) + + test('partial .env (only QAS_URL) warns and falls through', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, '.env'), `QAS_URL=${tenantUrl}\n`) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth status') + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/\.env.*QAS_TOKEN is missing/)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('partial .qaspherecli (only QAS_TOKEN) warns and reports not authenticated', async () => { + const exit = mockProcessExit() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, '.qaspherecli'), `QAS_TOKEN=${testApiKey}\n`) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth status').catch(() => {}) + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/\.qaspherecli.*QAS_URL is missing/)) + expect(log).toHaveBeenCalledWith('Not logged in.') + expect(exit).not.toHaveBeenCalled() // status shows "Not logged in." without exit(1) + } 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 + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString(), + tenantUrl, + }) + ) + + const refreshedAccessToken = 'tenantId.authId7chars.refreshedAccessToken' + const refreshedRefreshToken = 'tenantId.authId7chars.refreshedRefreshToken' + + let lastRefreshBody: Record | null = null + server.use( + http.post(`${tenantUrl}/api/oauth/token`, async ({ request }) => { + const body = (await request.json()) as Record + lastRefreshBody = body + 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, + refresh_token_expires_in: 90 * 24 * 3600, + }) + } + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'invalid refresh token' }, + { status: 401 } + ) + }), + http.get(`${tenantUrl}/api/public/v0/users/me`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `Bearer ${refreshedAccessToken}`) { + return HttpResponse.json({ user: testMeUser }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }) + ) + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith( + expect.stringContaining(`Credentials connected via ${tenantUrl}`) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + + // RFC 6749 §6: public clients must include client_id on refresh + expect(lastRefreshBody).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'qas-cli', + refresh_token: testRefreshToken, + }) + + // 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(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).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) + // Protocol error must clear the credentials file + expect(existsSync(credentialsFilePath())).toBe(false) + } finally { + process.chdir(origCwd) + } + }) + + test('transient 500 on refresh preserves credentials', async () => { + const exit = mockProcessExit() + + 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(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString(), + tenantUrl, + }) + ) + + server.use( + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'server_error', error_description: 'upstream unavailable' }, + { status: 500 } + ) + }) + ) + + 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('Could not refresh session')) + expect(exit).toHaveBeenCalledWith(1) + // Transport error must NOT clear credentials + expect(existsSync(credentialsFilePath())).toBe(true) + } finally { + process.chdir(origCwd) + } + }) + + test('protocol error warns when clear-credentials fails', async () => { + const exit = mockProcessExit() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + mockState.keyringMode = 'available' + const expiredCreds = { + type: 'oauth', + accessToken: 'expired-access-token', + refreshToken: 'expired-refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString(), + tenantUrl, + } + mockState.keyringStore.set('qasphere-cli:credentials', JSON.stringify(expiredCreds)) + + // Refresh fails with a protocol error → resolver will try to clear keyring + server.use( + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'refresh token revoked' }, + { status: 401 } + ) + }) + ) + + // Make keyring delete throw so the clear path falls into the warn branch + const originalDelete = mockState.keyringStore.delete.bind(mockState.keyringStore) + mockState.keyringStore.delete = () => { + throw new Error('Keyring locked') + } + + try { + await runCommand('auth status').catch(() => {}) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('could not clear stale credentials') + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Keyring locked')) + expect(err).toHaveBeenCalledWith(expect.stringContaining('Session expired')) + expect(exit).toHaveBeenCalledWith(1) + } finally { + mockState.keyringStore.delete = originalDelete + } + }) + + test('network failure on refresh preserves credentials', async () => { + const exit = mockProcessExit() + + 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(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString(), + tenantUrl, + }) + ) + + server.use( + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.error() + }) + ) + + 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('Could not refresh session')) + expect(exit).toHaveBeenCalledWith(1) + expect(existsSync(credentialsFilePath())).toBe(true) + } finally { + process.chdir(origCwd) + } + }) + + test('refreshes expired access token stored in keyring and persists back to keyring', async () => { + mockState.keyringMode = 'available' + writeOAuthCredentials('keyring') + + // Override the access token to be expired + const key = 'qasphere-cli:credentials' + const creds = JSON.parse(mockState.keyringStore.get(key)!) as Record + creds.accessTokenExpiresAt = new Date(Date.now() - 60_000).toISOString() + mockState.keyringStore.set(key, JSON.stringify(creds)) + + 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, + refresh_token_expires_in: 90 * 24 * 3600, + }) + } + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'invalid refresh token' }, + { status: 401 } + ) + }), + http.get(`${tenantUrl}/api/public/v0/users/me`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `Bearer ${refreshedAccessToken}`) { + return HttpResponse.json({ user: testMeUser }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }) + ) + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith( + expect.stringContaining(`Credentials connected via ${tenantUrl}`) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + + // Verify keyring was updated with new tokens + const updated = JSON.parse(mockState.keyringStore.get(key)!) as Record + expect(updated.accessToken).toBe(refreshedAccessToken) + expect(updated.refreshToken).toBe(refreshedRefreshToken) + + // Verify credentials file was NOT created + expect(existsSync(credentialsFilePath())).toBe(false) + }) +}) + +describe('logout error handling', () => { + test('clearCredentials failure shows error and exits', async () => { + const exit = mockProcessExit() + + mockState.keyringMode = 'available' + writeOAuthCredentials('keyring') + + // Make keyring deletion throw + const originalDelete = mockState.keyringStore.delete.bind(mockState.keyringStore) + mockState.keyringStore.delete = () => { + throw new Error('Keyring access denied') + } + + try { + await runCommand('auth logout').catch(() => {}) + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Could not clear credentials from keyring') + ) + expect(err).toHaveBeenCalledWith(expect.stringContaining('Keyring access denied')) + expect(exit).toHaveBeenCalledWith(1) + } finally { + mockState.keyringStore.delete = originalDelete + } + }) +}) diff --git a/src/tests/bearer-upload.spec.ts b/src/tests/bearer-upload.spec.ts new file mode 100644 index 0000000..f6b31e6 --- /dev/null +++ b/src/tests/bearer-upload.spec.ts @@ -0,0 +1,150 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { mkdirSync, rmSync, 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' +import { runTestCases } from './fixtures/testcases' +import { countMockedApiCalls } from './utils' + +const projectCode = 'TEST' +const runId = '1' +const baseURL = 'https://oauth-tenant.eu1.qasphere.com' +const runURL = `${baseURL}/project/${projectCode}/run/${runId}` +const testAccessToken = 'tenantId.authId7chars.bearerAccessToken' +const testRefreshToken = 'tenantId.authId7chars.bearerRefreshToken' +const expectedAuth = `Bearer ${testAccessToken}` + +const mockState = vi.hoisted(() => ({ + testHomeDir: '', +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => mockState.testHomeDir } +}) + +// Prevent the real OS keyring from leaking credentials into tests +vi.mock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + setPassword(): void { + throw new Error('keyring disabled in test') + } + getPassword(): string { + throw new Error('keyring disabled in test') + } + deletePassword(): void { + throw new Error('keyring disabled in test') + } + }, +})) + +const server = setupServer( + http.get(`${baseURL}/api/public/v0/project/${projectCode}`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ exists: true }) + }), + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/folders`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ data: [], total: 0, page: 1, limit: 50 }) + }), + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ data: [], total: 0, page: 1, limit: 50 }) + }), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/seq`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ data: runTestCases, total: runTestCases.length }) + }), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/run`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ id: parseInt(runId) }) + }), + http.get(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ tcases: runTestCases }) + }), + http.post( + new RegExp(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/result/batch`), + ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ ids: [0] }) + } + ), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/log`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ id: 'log-1' }) + }), + http.post(`${baseURL}/api/public/v0/file/batch`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ files: [] }) + }) +) + +const countResultUploadApiCalls = () => + countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/result/batch')) + +function writeOAuthCredentialsFile() { + const creds = { + type: 'oauth', + accessToken: testAccessToken, + refreshToken: testRefreshToken, + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString(), + tenantUrl: baseURL, + } + const credDir = join(mockState.testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), JSON.stringify(creds)) +} + +async function runFresh(args: string) { + vi.resetModules() + const { run } = await import('../commands/main') + await run(args.split(' ')) +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +beforeEach(() => { + mockState.testHomeDir = join( + tmpdir(), + `qas-cli-bearer-${Date.now()}-${Math.random().toString(36).slice(2)}` + ) + mkdirSync(mockState.testHomeDir, { recursive: true }) + + // Strip API-key env so resolveCredentialSource falls through to the credentials file + delete process.env.QAS_TOKEN + delete process.env.QAS_URL + + writeOAuthCredentialsFile() +}) + +afterEach(() => { + server.resetHandlers() + server.events.removeAllListeners() + rmSync(mockState.testHomeDir, { recursive: true, force: true }) +}) + +describe('upload commands with OAuth bearer auth', () => { + test('junit-upload sends Authorization: Bearer', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await runFresh(`junit-upload -r ${runURL} ./src/tests/fixtures/junit-xml/matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) + }) + + test('playwright-json-upload sends Authorization: Bearer', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await runFresh( + `playwright-json-upload -r ${runURL} ./src/tests/fixtures/playwright-json/matching-tcases.json` + ) + expect(numResultUploadCalls()).toBe(1) + }) + + test('allure-upload sends Authorization: Bearer', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await runFresh(`allure-upload -r ${runURL} ./src/tests/fixtures/allure/matching-tcases`) + expect(numResultUploadCalls()).toBe(1) + }) +}) diff --git a/src/tests/browser.spec.ts b/src/tests/browser.spec.ts new file mode 100644 index 0000000..10e43fe --- /dev/null +++ b/src/tests/browser.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})) + +describe('openBrowser', () => { + let err: ReturnType + + beforeEach(() => { + execFileMock.mockReset() + err = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('throws on javascript: URLs', async () => { + const { openBrowser } = await import('../utils/browser') + expect(() => openBrowser('javascript:alert(1)')).toThrow(/non-http\(s\)/) + expect(execFileMock).not.toHaveBeenCalled() + }) + + test('throws on file: URLs', async () => { + const { openBrowser } = await import('../utils/browser') + expect(() => openBrowser('file:///etc/passwd')).toThrow(/non-http\(s\)/) + expect(execFileMock).not.toHaveBeenCalled() + }) + + test('throws on data: URLs', async () => { + const { openBrowser } = await import('../utils/browser') + expect(() => openBrowser('data:text/html,')).toThrow(/non-http\(s\)/) + expect(execFileMock).not.toHaveBeenCalled() + }) + + test('accepts http URLs', async () => { + const { openBrowser } = await import('../utils/browser') + openBrowser('http://example.com') + expect(execFileMock).toHaveBeenCalledOnce() + }) + + test('accepts https URLs', async () => { + const { openBrowser } = await import('../utils/browser') + openBrowser('https://example.com/path?q=1') + expect(execFileMock).toHaveBeenCalledOnce() + }) + + test('throws on malformed URL', async () => { + const { openBrowser } = await import('../utils/browser') + expect(() => openBrowser('not a url')).toThrow() + expect(execFileMock).not.toHaveBeenCalled() + }) + + test('includes stderr in error message on failure', async () => { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + cb(new Error('spawn failed'), '', 'xdg-open: no application registered') + } + ) + const { openBrowser } = await import('../utils/browser') + openBrowser('http://example.com') + expect(err).toHaveBeenCalledWith(expect.stringContaining('no application registered')) + expect(err).toHaveBeenCalledWith(expect.stringContaining('visit the URL manually')) + }) + + test('omits parenthetical when stderr is empty', async () => { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + cb(new Error('spawn failed'), '', '') + } + ) + const { openBrowser } = await import('../utils/browser') + openBrowser('http://example.com') + const call = err.mock.calls[0]?.[0] as string + expect(call).toBe('Could not open browser. Please visit the URL manually.') + }) + + test('no error logged when execFile callback reports success', async () => { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + cb(null, '', '') + } + ) + const { openBrowser } = await import('../utils/browser') + openBrowser('http://example.com') + expect(err).not.toHaveBeenCalled() + }) +}) diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index e038a76..24a0104 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -24,8 +24,10 @@ process.env['QAS_URL'] = baseURL let lastCreatedRunTitle = '' // Stores title in the request, for the last create run API call let createRunTitleConflict = false // If true, the create run API returns a title conflict error let createTCasesResponse: CreateTCasesResponse | null = null // Stores mock response for the create tcases API call -let overriddenGetPaginatedTCasesResponse: PaginatedResponse | null = null // Stores overridden (non-default) response for the get tcases API call -let overriddenGetFoldersResponse: PaginatedResponse | null = null // Stores overridden (non-default) response for the get folders API call +// Mocks intentionally include only the fields the SUT touches, so the response +// types are loosened to Partial<> rather than the full API shape. +let overriddenGetPaginatedTCasesResponse: PaginatedResponse> | null = null +let overriddenGetFoldersResponse: PaginatedResponse> | null = null const server = setupServer( http.get(`${baseURL}/api/public/v0/project/${projectCode}`, ({ request }) => { @@ -604,7 +606,6 @@ fileTypesWithAllure.forEach((fileType) => { seq: 6, title: 'The cart is still filled after refreshing the page', version: 1, - projectId: 'projectid', folderId: 1, }, ], @@ -846,3 +847,48 @@ describe('Run-level log upload', () => { expect(numResultUploadCalls()).toBe(1) }) }) + +describe('Trailing slash in QAS_URL (regression for --run-url equality)', () => { + beforeEach(() => { + process.env['QAS_URL'] = `${baseURL}/` + }) + + afterEach(() => { + process.env['QAS_URL'] = baseURL + }) + + test('JUnit upload with --run-url succeeds when QAS_URL has trailing slash', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run(`junit-upload -r ${runURL} ./src/tests/fixtures/junit-xml/matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) + }) +}) + +describe('--run-url host mismatch', () => { + test('rejects --run-url whose host differs from authenticated tenant', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const otherHostRunUrl = `https://other-tenant.qasphere.com/project/${projectCode}/run/${runId}` + + try { + await expect( + run(`junit-upload -r ${otherHostRunUrl} ./src/tests/fixtures/junit-xml/matching-tcases.xml`) + ).rejects.toThrow('process.exit') + + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('does not match the authenticated tenant') + ) + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('https://other-tenant.qasphere.com') + ) + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(baseURL)) + expect(exit).toHaveBeenCalledWith(1) + } finally { + exit.mockRestore() + errSpy.mockRestore() + } + }) +}) diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 0000000..0bee44e --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,27 @@ +import { execFile } from 'node:child_process' + +const onError = (err: Error | null, _stdout: string, stderr: string) => { + if (!err) return + const detail = stderr.trim() + const suffix = detail ? ` (${detail})` : '' + console.error(`Could not open browser${suffix}. Please visit the URL manually.`) +} + +export function openBrowser(url: string): void { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Refusing to open browser for non-http(s) URL: ${parsed.protocol}`) + } + + 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..5e06f02 --- /dev/null +++ b/src/utils/credentials/index.ts @@ -0,0 +1,16 @@ +export type { + OAuthCredentials, + CredentialSource, + ApiKeyCredentialSource, + OAuthCredentialSource, + AuthConfig, +} from './types' +export { saveCredentials, clearCredentials, credentialsFromTokenResponse } 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..2a6ea7c --- /dev/null +++ b/src/utils/credentials/resolvers.ts @@ -0,0 +1,236 @@ +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, OAuthProtocolError } from '../../api/oauth' +import { + saveCredentials, + clearCredentials, + credentialsFromTokenResponse, + loadCredentialsFromKeyring, + loadCredentialsFromFile, +} from './storage' +import type { 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 + +const normalizeTenantUrl = (url: string) => url.replace(/\/+$/, '') + +interface ResolveResult { + credentials: ApiKeyResolved | null + errorMessage?: string +} + +function partialConfigMessage( + sourceLabel: string, + hasToken: boolean, + hasUrl: boolean +): string | undefined { + if (hasToken === hasUrl) return undefined + const missing = hasToken ? 'QAS_URL' : 'QAS_TOKEN' + const present = hasToken ? 'QAS_TOKEN' : 'QAS_URL' + return `${sourceLabel}: ${present} is set but ${missing} is missing — skipping.` +} + +function resolveFromEnvVars(): ResolveResult { + const token = process.env.QAS_TOKEN + const url = process.env.QAS_URL + if (token && url) { + return { + credentials: { + token, + tenantUrl: normalizeTenantUrl(url), + authType: 'apikey', + source: 'env_var', + }, + } + } + return { + credentials: null, + errorMessage: partialConfigMessage('Environment variables', !!token, !!url), + } +} + +function resolveFromDotenv(): ResolveResult { + const dotenvPath = join(process.cwd(), '.env') + if (!existsSync(dotenvPath)) return { credentials: null } + + const fileEnvs: DotenvPopulateInput = {} + config({ path: dotenvPath, processEnv: fileEnvs }) + const token = fileEnvs.QAS_TOKEN + const url = fileEnvs.QAS_URL + if (token && url) { + return { + credentials: { + token, + tenantUrl: normalizeTenantUrl(url), + authType: 'apikey', + source: '.env', + }, + } + } + return { + credentials: null, + errorMessage: partialConfigMessage(`.env file at ${dotenvPath}`, !!token, !!url), + } +} + +function resolveFromQaspherecli(): ResolveResult { + let dir = process.cwd() + for (;;) { + const envPath = join(dir, qasEnvFile) + if (existsSync(envPath)) { + const fileEnvs: DotenvPopulateInput = {} + config({ path: envPath, processEnv: fileEnvs }) + const token = fileEnvs.QAS_TOKEN + const url = fileEnvs.QAS_URL + if (token && url) { + return { + credentials: { + token, + tenantUrl: normalizeTenantUrl(url), + authType: 'apikey', + source: '.qaspherecli', + }, + } + } + return { + credentials: null, + errorMessage: partialConfigMessage(`.qaspherecli at ${envPath}`, !!token, !!url), + } + } + + const parentDir = dirname(dir) + if (parentDir === dir) break + dir = parentDir + } + return { credentials: null } +} + +function warnIfHasError(result: ResolveResult): void { + if (result.errorMessage) { + console.warn(chalk.yellow('Warning:') + ` ${result.errorMessage}`) + } +} + +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.credentials) return envResult.credentials + warnIfHasError(envResult) + + // 2. .env file in cwd + const dotenvResult = resolveFromDotenv() + if (dotenvResult.credentials) return dotenvResult.credentials + warnIfHasError(dotenvResult) + + // 3. Keyring or credentials.json (OAuth only) + const persisted = await resolvePersistedCredentialSource() + if (persisted) return persisted + + // 4. .qaspherecli file + const cliResult = resolveFromQaspherecli() + warnIfHasError(cliResult) + return cliResult.credentials +} + +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 = credentialsFromTokenResponse(tokenResponse, resolved.credentials.tenantUrl) + const newSource = await saveCredentials(updated) + return { credentials: updated, authType: 'bearer', source: newSource } + } catch (e) { + if (e instanceof OAuthProtocolError) { + // Protocol-level rejection (e.g., invalid_grant) — credentials are no longer + // valid; clear them and ask the user to re-authenticate. + try { + await clearCredentials(resolved.source) + } catch (clearErr) { + const msg = clearErr instanceof Error ? clearErr.message : String(clearErr) + console.warn(chalk.yellow('Warning:') + ` could not clear stale credentials: ${msg}`) + } + + console.error(chalk.red('Session expired.') + ' Please log in again:') + console.error(chalk.green(' qasphere auth login')) + process.exit(1) + } + + // Transport error (network failure, 5xx, malformed payload). Do NOT clear + // credentials — they may still be valid; the failure is upstream. + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Could not refresh session:') + ` ${message}. Please try again later.`) + process.exit(1) + } +} + +export async function resolveAuth(): Promise { + const resolved = await resolveCredentialSource() + if (!resolved) { + console.error(chalk.red('Not authenticated.')) + console.error('\nYou can authenticate using:') + console.error(chalk.green(' qasphere auth login')) + console.error('\nOr create a .qaspherecli file with the following content:') + console.error( + 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.error('\nOr export them as environment variables:') + console.error( + 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..7620059 --- /dev/null +++ b/src/utils/credentials/storage.ts @@ -0,0 +1,93 @@ +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 type { OAuthTokenResponse } from '../../api/oauth' +import { oauthCredentialsSchema, type OAuthCredentials, type OAuthCredentialSource } from './types' + +const CONFIG_DIR = join(homedir(), '.config', 'qasphere') +const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json') + +export function credentialsFromTokenResponse( + response: OAuthTokenResponse, + tenantUrl: string +): OAuthCredentials { + const now = Date.now() + return { + type: 'oauth', + accessToken: response.access_token, + refreshToken: response.refresh_token, + accessTokenExpiresAt: new Date(now + response.expires_in * 1000).toISOString(), + refreshTokenExpiresAt: new Date(now + response.refresh_token_expires_in * 1000).toISOString(), + tenantUrl, + } +} + +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: OAuthCredentialSource): Promise { + if (source === 'keyring') { + const entry = await getKeyringEntry() + if (!entry) throw new Error('Keyring is not available') + entry.deletePassword() + return + } + unlinkSync(CREDENTIALS_FILE) +} diff --git a/src/utils/credentials/types.ts b/src/utils/credentials/types.ts new file mode 100644 index 0000000..3ca6c57 --- /dev/null +++ b/src/utils/credentials/types.ts @@ -0,0 +1,40 @@ +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 + refreshTokenExpiresAt: z.string().datetime(), // ISO 8601 + tenantUrl: z.string().url(), +}) + +export type OAuthCredentials = z.infer + +export type ApiKeyCredentialSource = 'env_var' | '.env' | '.qaspherecli' + +export type OAuthCredentialSource = 'keyring' | 'credentials.json' + +export type CredentialSource = ApiKeyCredentialSource | OAuthCredentialSource + +export interface ApiKeyResolved { + token: string + tenantUrl: string + authType: 'apikey' + source: ApiKeyCredentialSource +} + +export interface OAuthResolved { + credentials: OAuthCredentials + authType: 'bearer' + source: OAuthCredentialSource +} + +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 8a2ce09..763f79d 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, TestCaseMarker, TestCaseResult } from './types' import { DuplicateTCaseMapping, TCaseTarget, mapResolvedResultsToTCases } from './mapping' @@ -84,12 +85,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) } @@ -109,7 +109,7 @@ export class ResultUploadCommandHandler { const urlParsed = parseRunUrl(this.args) if (urlParsed.url !== this.baseUrl) { printErrorThenExit( - `Invalid --run-url specified. Must be in the format: ${this.baseUrl}/project/PROJECT/run/RUN` + `--run-url host (${urlParsed.url}) does not match the authenticated tenant (${this.baseUrl}). Run "qasphere auth status" to see which tenant your credentials belong to.` ) } @@ -557,11 +557,9 @@ export class ResultUploadCommandHandler { runFailureLogs: string }) { const runUrl = `${this.baseUrl}/project/${projectCode}/run/${runId}` - const uploader = new ResultUploader( - this.type, - { ...this.args, runUrl }, - { skipDuplicateValidation: this.skipUploaderDuplicateValidation } - ) + const uploader = new ResultUploader(this.type, { ...this.args, runUrl }, this.auth, { + skipDuplicateValidation: this.skipUploaderDuplicateValidation, + }) await uploader.handle(results, runFailureLogs) } diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index 297c67c..de5c61c 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 { DuplicateTCaseMapping, TCaseWithResult, mapResolvedResultsToTCases } from './mapping' @@ -21,14 +22,14 @@ export class ResultUploader { constructor( private type: UploadCommandType, private args: Arguments, + auth: AuthConfig, private options: { skipDuplicateValidation?: boolean } = {} ) { - 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) {