diff --git a/src/commands/test.ts b/src/commands/test.ts index ca6801c..16cfa41 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -92,7 +92,7 @@ import { import { createTicker } from '../lib/ticker.js'; import { RateThrottle } from '../lib/rate-throttle.js'; import { resolvePortalBase, resolvePortalUrl } from '../lib/facade.js'; -import { loadConfig } from '../lib/config.js'; +import { loadConfig, readConfigFileSettings } from '../lib/config.js'; import { flakyExitCode, renderFlakyText, @@ -552,7 +552,7 @@ export async function runList(opts: ListOptions, deps: TestDeps = {}): Promise

{ assertIdempotencyKey(opts.idempotencyKey); - const projectId = resolveProjectId(opts.projectId, deps); + const projectId = resolveProjectId(opts.projectId, deps, opts.profile); requireProjectId(projectId); if ( !Number.isInteger(opts.maxConcurrency) || @@ -9176,10 +9176,14 @@ export function createTestCommand(deps: TestDeps = {}): Command { if (isAll) { // --all path: wave-ordered fresh batch run. - const projectId = resolveProjectId(cmdOpts.project, deps); + const projectId = resolveProjectId( + cmdOpts.project, + deps, + resolveCommonOptions(command).profile, + ); requireProjectId( projectId, - '--all requires a project id - pass --project or set TESTSPRITE_PROJECT_ID', + '--all requires a project id - pass --project , set TESTSPRITE_PROJECT_ID, or set project_id in ~/.testsprite/config (or TESTSPRITE_CONFIG_FILE)', ); // --target-url has no effect on the --all batch path: a BE test's base // URL is baked into its code, and the unified engine resolves each @@ -9805,16 +9809,28 @@ interface StepsFlagOpts { runId?: string; } -function resolveProjectId(projectId: string | undefined, deps: TestDeps): string | undefined { +/** + * Resolve the effective project id: the `--project` flag when set, then the + * `TESTSPRITE_PROJECT_ID` env var, then the `project_id` persisted in the + * settings config file (`~/.testsprite/config`) — flag > env > config file — + * so a repo/agent can pin the project once instead of repeating it per command. + */ +function resolveProjectId( + projectId: string | undefined, + deps: TestDeps, + profile = 'default', +): string | undefined { const explicit = projectId?.trim(); if (explicit && explicit.length > 0) return explicit; const envValue = (deps.env ?? process.env).TESTSPRITE_PROJECT_ID; const trimmed = envValue?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; + if (trimmed && trimmed.length > 0) return trimmed; + const fromConfig = readConfigFileSettings(profile, { env: deps.env }).projectId; + return typeof fromConfig === 'string' && fromConfig.length > 0 ? fromConfig : undefined; } function requireProjectId( projectId: string | undefined, - message = 'is required; pass --project or set TESTSPRITE_PROJECT_ID', + message = 'is required; pass --project , set TESTSPRITE_PROJECT_ID, or set project_id in the config file (~/.testsprite/config or TESTSPRITE_CONFIG_FILE)', ): asserts projectId is string { if (typeof projectId !== 'string' || projectId.length === 0) { throw localValidationError('project', message); diff --git a/src/index.ts b/src/index.ts index 31d3399..225758f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { import { createProjectCommand } from './commands/project.js'; import { createTestCommand } from './commands/test.js'; import { createUsageCommand } from './commands/usage.js'; +import { readConfigFileSettings } from './lib/config.js'; import { ApiError, CLIError, InterruptError, RequestTimeoutError } from './lib/errors.js'; import { installBrokenPipeGuard, installSignalHandlers } from './lib/interrupt.js'; import { Output, isOutputMode } from './lib/output.js'; @@ -34,6 +35,35 @@ if (shouldRejectNodeVersion(process.versions.node)) { const program = new Command(); +/** + * Profile used for CONFIG-FILE DEFAULTS. The `--output` default must be + * computed before Commander parses argv, so honor the same precedence the + * real resolution uses (`--profile` flag > TESTSPRITE_PROFILE > "default") + * by peeking argv for the flag. Both `--profile ` and `--profile=` + * spellings are recognized; anything unparseable falls back down the chain. + */ +function profileForDefaults(): string { + const argv = process.argv; + const flagIndex = argv.indexOf('--profile'); + const next = flagIndex !== -1 ? argv[flagIndex + 1] : undefined; + if (typeof next === 'string' && next.length > 0 && !next.startsWith('-')) return next; + const inline = argv.find(arg => arg.startsWith('--profile=')); + const inlineValue = inline?.slice('--profile='.length); + if (typeof inlineValue === 'string' && inlineValue.length > 0) return inlineValue; + return process.env.TESTSPRITE_PROFILE ?? 'default'; +} + +/** + * Default for the global `--output` flag: the `output` key of the selected + * profile's section in `~/.testsprite/config` when present. An explicit + * `--output` flag still wins; an invalid or absent value falls back to + * 'text' (the historical default). + */ +function configFileOutputDefault(): string { + const settings = readConfigFileSettings(profileForDefaults()); + return isOutputMode(settings.output) ? settings.output : 'text'; +} + // exitOverride() causes Commander to throw CommanderError instead of calling // process.exit() directly, giving our catch block a chance to remap error // exit codes (e.g. missing-argument → exit 5 per taxonomy). @@ -43,7 +73,7 @@ program .name('testsprite') .description('Official TestSprite command-line interface') .version(VERSION) - .option('--output ', 'Output format (json|text)', 'text') + .option('--output ', 'Output format (json|text)', configFileOutputDefault()) .option('--profile ', 'Configuration profile to use') .option('--endpoint-url ', 'Override the API endpoint host') .option( diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 2a58fc8..5e7dba6 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,8 +1,8 @@ -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; -import { defaultConfigPath, loadConfig } from './config.js'; +import { defaultConfigPath, loadConfig, readConfigFileSettings } from './config.js'; import { writeProfile } from './credentials.js'; let tmpRoot: string; @@ -127,3 +127,53 @@ describe('defaultConfigPath', () => { expect(defaultConfigPath()).toBe(join(homedir(), '.testsprite', 'config')); }); }); + +describe('readConfigFileSettings + the config-file layer of loadConfig', () => { + function writeConfigFile(content: string): string { + const path = join(mkdtempSync(join(tmpdir(), 'testsprite-config-')), 'config'); + writeFileSync(path, content, 'utf8'); + return path; + } + + it('reads endpoint_url/output/project_id from the profile section', () => { + const path = writeConfigFile( + '[default]\nendpoint_url = https://selfhosted.example.com\noutput = json\nproject_id = project_cfg\n', + ); + expect(readConfigFileSettings('default', { path })).toEqual({ + endpointUrl: 'https://selfhosted.example.com', + output: 'json', + projectId: 'project_cfg', + }); + // A different profile section is not leaked into. + expect(readConfigFileSettings('other', { path })).toEqual({}); + }); + + it('missing file and unknown keys are non-fatal', () => { + expect(readConfigFileSettings('default', { path: '/nope/definitely/missing' })).toEqual({}); + const path = writeConfigFile('[default]\nmystery_key = x\n'); + expect(readConfigFileSettings('default', { path })).toEqual({}); + }); + + it('resolves the path from TESTSPRITE_CONFIG_FILE when no explicit path is given', () => { + const path = writeConfigFile('[default]\nproject_id = project_from_env_path\n'); + const settings = readConfigFileSettings('default', { + env: { TESTSPRITE_CONFIG_FILE: path }, + }); + expect(settings.projectId).toBe('project_from_env_path'); + }); + + it('loadConfig: config-file endpoint_url sits above the built-in default and below env', () => { + const path = writeConfigFile('[default]\nendpoint_url = https://cfg.example.com\n'); + const missingCreds = join(mkdtempSync(join(tmpdir(), 'testsprite-creds-')), 'credentials'); + // Only the config file supplies a URL -> it wins over the built-in default. + const fromConfig = loadConfig({ env: {}, credentialsPath: missingCreds, configPath: path }); + expect(fromConfig.apiUrl).toBe('https://cfg.example.com'); + // Env still outranks the config file. + const fromEnv = loadConfig({ + env: { TESTSPRITE_API_URL: 'https://env.example.com' }, + credentialsPath: missingCreds, + configPath: path, + }); + expect(fromEnv.apiUrl).toBe('https://env.example.com'); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index a69f2e4..4a6110f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,12 @@ +import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { DEFAULT_PROFILE, defaultCredentialsPath, readProfile } from './credentials.js'; +import { + DEFAULT_PROFILE, + defaultCredentialsPath, + parseIniFile, + readProfile, +} from './credentials.js'; export interface Config { apiUrl: string; @@ -13,6 +19,8 @@ export interface LoadConfigOptions { endpointUrl?: string; env?: NodeJS.ProcessEnv; credentialsPath?: string; + /** Settings file override; defaults to `TESTSPRITE_CONFIG_FILE` or `~/.testsprite/config`. */ + configPath?: string; } const DEFAULT_API_URL = 'https://api.testsprite.com'; @@ -26,22 +34,79 @@ export function defaultConfigPath(): string { return join(homedir(), '.testsprite', 'config'); } +/** + * Non-credential defaults a user can persist in `~/.testsprite/config` + * (aws-cli's credentials-vs-config split: the key lives in `credentials`, + * settings live here). INI sections are profile names, mirroring the + * credentials file. + */ +export interface ConfigFileSettings { + endpointUrl?: string; + output?: string; + projectId?: string; +} + +/** INI key -> settings field. sourceRef: DOCUMENTATION.md configuration table. */ +const CONFIG_KEY_TO_FIELD: Record = { + endpoint_url: 'endpointUrl', + output: 'output', + project_id: 'projectId', +}; + +export interface ReadConfigFileOptions { + env?: NodeJS.ProcessEnv; + path?: string; +} + +/** + * Read the `[profile]` section of the settings config file. Missing or + * unreadable file (or section) yields `{}` so the cascade falls through to + * built-in defaults; the file is optional by design. Reuses the hardened INI + * parser the credentials file uses (null-prototype + proto-key guards). + */ +export function readConfigFileSettings( + profile: string, + options: ReadConfigFileOptions = {}, +): ConfigFileSettings { + const env = options.env ?? process.env; + const path = options.path ?? env.TESTSPRITE_CONFIG_FILE ?? defaultConfigPath(); + let content: string; + try { + if (!existsSync(path)) return {}; + content = readFileSync(path, 'utf-8'); + } catch { + // Unreadable settings file: settings are optional, never fatal. + return {}; + } + const section = parseIniFile(content)[profile]; + if (!section) return {}; + const settings: ConfigFileSettings = {}; + for (const [rawKey, field] of Object.entries(CONFIG_KEY_TO_FIELD)) { + const value = section[rawKey]; + if (typeof value === 'string' && value.length > 0) settings[field] = value; + } + return settings; +} + /** * Resolves the active profile name and its (apiUrl, apiKey) pair. * * Resolution order, highest precedence first: * profile name: options.profile > env.TESTSPRITE_PROFILE > "default" * apiKey: env.TESTSPRITE_API_KEY > credentials file profile entry - * apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > credentials file > built-in default + * apiUrl: options.endpointUrl > env.TESTSPRITE_API_URL > credentials file + * > config file `endpoint_url` > built-in default * * Env wins over the credentials file so CI / scripted callers can run without touching - * the user's ~/.testsprite/credentials. + * the user's ~/.testsprite/credentials. The config file sits just above the built-in + * default: it is where a user persists "this machine talks to this endpoint" once. */ export function loadConfig(options: LoadConfigOptions = {}): Config { const env = options.env ?? process.env; const profile = options.profile ?? normalizeEnvVar(env.TESTSPRITE_PROFILE) ?? DEFAULT_PROFILE; const credentialsPath = options.credentialsPath ?? defaultCredentialsPath(); const fileEntry = readProfile(profile, { path: credentialsPath }); + const settings = readConfigFileSettings(profile, { env, path: options.configPath }); // Empty / whitespace-only env vars are treated as unset so they do not // short-circuit the `??` chain (e.g. `export TESTSPRITE_API_URL=` or @@ -54,7 +119,12 @@ export function loadConfig(options: LoadConfigOptions = {}): Config { const envApiKey = normalizeEnvVar(env.TESTSPRITE_API_KEY); return { - apiUrl: options.endpointUrl ?? envApiUrl ?? fileEntry?.apiUrl ?? DEFAULT_API_URL, + apiUrl: + options.endpointUrl ?? + envApiUrl ?? + fileEntry?.apiUrl ?? + settings.endpointUrl ?? + DEFAULT_API_URL, apiKey: envApiKey ?? fileEntry?.apiKey, profile, }; diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index c67944b..ded16b9 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -100,20 +100,37 @@ const CREDENTIALS_LOCK_RETRY_MS = 25; const CREDENTIALS_LOCK_WAIT_MS = 5_000; const CREDENTIALS_LOCK_STALE_MS = 30_000; -export function parseCredentials(content: string): CredentialsFile { - const result: CredentialsFile = {}; - let currentEntry: ProfileEntry | null = null; +const DANGEROUS_INI_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Generic hardened INI walk shared by the credentials file and the settings + * config file (`~/.testsprite/config`). Returns every `[section]`'s raw + * key=value pairs; callers map the keys they understand. + */ +export function parseIniFile(content: string): Record> { + // Null-prototype accumulator so a `[__proto__]` / `[constructor]` section + // cannot alias a shared prototype object and let the following key=value + // lines pollute every object in the process (prototype-pollution hardening). + // The result is copied into a plain object on return for caller back-compat. + const result: Record> = Object.create(null); + let currentEntry: Record | null = null; for (const rawLine of content.split('\n')) { const line = rawLine.trim(); if (line === '' || line.startsWith('#') || line.startsWith(';')) continue; const sectionMatch = /^\[([^\]]+)\]$/.exec(line); if (sectionMatch) { const sectionName = sectionMatch[1]!.trim(); + // Defense in depth alongside the null-prototype accumulator: never treat a + // prototype-polluting key as a profile section. Skip its key=value lines. + if (DANGEROUS_INI_KEYS.has(sectionName)) { + currentEntry = null; + continue; + } const existing = result[sectionName]; if (existing) { currentEntry = existing; } else { - const newEntry: ProfileEntry = {}; + const newEntry: Record = Object.create(null) as Record; result[sectionName] = newEntry; currentEntry = newEntry; } @@ -124,8 +141,27 @@ export function parseCredentials(content: string): CredentialsFile { if (eqIndex < 0) continue; const rawKey = line.slice(0, eqIndex).trim(); const rawValue = line.slice(eqIndex + 1).trim(); - const field = FILE_KEY_TO_FIELD[rawKey]; - if (field) currentEntry[field] = rawValue; + // Same guard for keys: `__proto__ = x` must never become a property write + // on a shared prototype when a caller copies the section into a plain map. + if (DANGEROUS_INI_KEYS.has(rawKey)) continue; + currentEntry[rawKey] = rawValue; + } + // Return plain objects so callers (and test matchers) see normal prototypes. + const plain: Record> = {}; + for (const [name, entry] of Object.entries(result)) plain[name] = { ...entry }; + return plain; +} + +export function parseCredentials(content: string): CredentialsFile { + const sections = parseIniFile(content); + const result: CredentialsFile = {}; + for (const [name, keyValues] of Object.entries(sections)) { + const entry: ProfileEntry = {}; + for (const [rawKey, rawValue] of Object.entries(keyValues)) { + const field = FILE_KEY_TO_FIELD[rawKey]; + if (field) entry[field] = rawValue; + } + result[name] = entry; } return result; }