diff --git a/src/lib/credentials.test.ts b/src/lib/credentials.test.ts index 8be03af..ac038f3 100644 --- a/src/lib/credentials.test.ts +++ b/src/lib/credentials.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_PROFILE, + assertValidProfileName, defaultCredentialsPath, deleteProfile, ensureRestrictiveMode, @@ -13,6 +14,7 @@ import { serializeCredentials, writeProfile, } from './credentials.js'; +import { ApiError } from './errors.js'; let tmpRoot: string; let credentialsPath: string; @@ -168,3 +170,42 @@ describe('defaultCredentialsPath', () => { expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true); }); }); + +describe('assertValidProfileName / profile-name guard', () => { + it('accepts conventional profile names', () => { + for (const name of ['default', 'dev', 'prod', 'ci-staging', 'team.qa', 'a_b', 'P1']) { + expect(() => assertValidProfileName(name)).not.toThrow(); + } + }); + + it('rejects names that would corrupt the INI file, with a VALIDATION_ERROR (exit 5)', () => { + // `prod]` -> `[prod]]` (unreadable); newline splits the header; padded + // names do not round-trip (the parser trims section names); empty is not a + // valid section. + for (const name of ['prod]', '[weird', 'a\nb', ' spaced ', 'has space', '', 'a/b']) { + let caught: unknown; + try { + assertValidProfileName(name); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ApiError); + const apiErr = caught as ApiError; + expect(apiErr.code).toBe('VALIDATION_ERROR'); + expect(apiErr.exitCode).toBe(5); + expect(apiErr.nextAction).toContain('profile'); + } + }); + + it('writeProfile rejects a malformed name and does NOT create the file', () => { + expect(() => writeProfile('prod]', { apiKey: 'sk-1' }, { path: credentialsPath })).toThrow( + ApiError, + ); + expect(existsSync(credentialsPath)).toBe(false); + }); + + it('readProfile and deleteProfile reject a malformed name', () => { + expect(() => readProfile('a\nb', { path: credentialsPath })).toThrow(ApiError); + expect(() => deleteProfile('a\nb', { path: credentialsPath })).toThrow(ApiError); + }); +}); diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index c59de98..c885c66 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -9,9 +9,43 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { localValidationError } from './errors.js'; export const DEFAULT_PROFILE = 'default'; +/** + * Allowed profile-name characters. A profile name is written verbatim as an + * INI section header (`[name]`) in the credentials file, so any character that + * breaks that grammar must be rejected: + * - `]` closes the header early โ€” `prod]` serialises to `[prod]]`, which the + * section regex cannot match, so the api_key/api_url lines that follow are + * silently dropped on read (the credential never persists). + * - CR/LF splits the header across lines, corrupting the file. + * - leading/trailing whitespace does not round-trip โ€” the parser trims + * section names, so `[ prod ]` reads back as `prod`. + * A conservative allowlist (letters, digits, dot, underscore, hyphen) matches + * conventional profile names (`default`, `prod`, `ci-staging`, `team.qa`) and + * cannot corrupt the file. + */ +const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/; + +/** + * Throw a typed VALIDATION_ERROR (exit 5) when `profile` is not a safe INI + * section name. Guards every credential read/write so a malformed `--profile` + * (or `TESTSPRITE_PROFILE`) value fails loudly instead of silently corrupting + * `~/.testsprite/credentials` or failing to persist a key written by `setup`. + */ +export function assertValidProfileName(profile: string): void { + if (!PROFILE_NAME_RE.test(profile)) { + throw localValidationError( + 'profile', + 'must contain only letters, digits, dot, underscore, or hyphen (no spaces, brackets, or newlines)', + undefined, + 'flag', + ); + } +} + export function defaultCredentialsPath(): string { return join(homedir(), '.testsprite', 'credentials'); } @@ -99,6 +133,7 @@ export function readProfile( profile: string, options: CredentialsOptions = {}, ): ProfileEntry | undefined { + assertValidProfileName(profile); const file = readCredentialsFile(options); return file[profile]; } @@ -108,6 +143,7 @@ export function writeProfile( entry: ProfileEntry, options: CredentialsOptions = {}, ): void { + assertValidProfileName(profile); const path = resolvePath(options); const file = readCredentialsFile(options); file[profile] = { ...file[profile], ...entry }; @@ -115,6 +151,7 @@ export function writeProfile( } export function deleteProfile(profile: string, options: CredentialsOptions = {}): boolean { + assertValidProfileName(profile); const path = resolvePath(options); const file = readCredentialsFile(options); if (!(profile in file)) return false; diff --git a/test/cli.subprocess.test.ts b/test/cli.subprocess.test.ts index 8cbfa59..69db582 100644 --- a/test/cli.subprocess.test.ts +++ b/test/cli.subprocess.test.ts @@ -499,6 +499,23 @@ describe('project list subprocess', () => { }, 30_000); }); +describe('a malformed --profile is rejected (exit 5), not silently corrupting credentials', () => { + // A profile name becomes an INI section header (`[name]`). `prod]` would + // serialise to `[prod]]`, which the parser cannot read back โ€” `setup` would + // report success while the key silently fails to persist. The guard fires on + // any credential read/write path. + it('exits 5 with a VALIDATION_ERROR naming the profile flag', async () => { + const result = await runCli(['--output', 'json', '--profile', 'prod]', 'project', 'list'], { + TESTSPRITE_API_KEY: 'sk-subproc', + TESTSPRITE_API_URL: baseUrl, + }); + expect(result.exitCode).toBe(5); + const parsed = JSON.parse(result.stderr) as { error: { code: string; nextAction: string } }; + expect(parsed.error.code).toBe('VALIDATION_ERROR'); + expect(parsed.error.nextAction).toContain('profile'); + }, 30_000); +}); + describe('project get subprocess', () => { it('--output json returns the ยง6.1 Project shape', async () => { const result = await runCli(['--output', 'json', 'project', 'get', 'project_subproc'], {