diff --git a/src/diagnostics/stellar.ts b/src/diagnostics/stellar.ts index 3d8e559..5bbefc3 100644 --- a/src/diagnostics/stellar.ts +++ b/src/diagnostics/stellar.ts @@ -9,7 +9,8 @@ const MINIMUM_STELLAR_CLI = { major: 27, minor: 1, patch: 0 } as const; function parseStellarVersion(output: string): string | null { const value = output.trim(); - const match = /^v?(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec(value); + const match = + /^v?(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(value); return match?.[1] ?? null; } diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 32a9251..c00cac6 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -13,3 +13,15 @@ export { withTempDirectory, type TempDirectoryFixture, } from './temp-directory.js'; +export { + SUPPORTED_STELLAR_VERSION_FIXTURES, + UNSUPPORTED_STELLAR_VERSION_FIXTURES, + MALFORMED_STELLAR_OUTPUT_FIXTURES, + NON_ZERO_EXECUTION_FIXTURES, + MISSING_EXECUTABLE_FIXTURES, + type SupportedVersionFixture, + type UnsupportedVersionFixture, + type MalformedOutputFixture, + type NonZeroExecutionFixture, + type MissingExecutableFixture, +} from './stellar-diagnostic-fixtures.js'; diff --git a/tests/helpers/stellar-diagnostic-fixtures.ts b/tests/helpers/stellar-diagnostic-fixtures.ts new file mode 100644 index 0000000..5057c9b --- /dev/null +++ b/tests/helpers/stellar-diagnostic-fixtures.ts @@ -0,0 +1,367 @@ +/** + * Diagnostic test fixtures for Stellar CLI version parsing and execution. + * + * Scope: + * - Plain semantic versions and whitespace variants + * - Supported version floor (>=27.1.0) and unsupported older baselines + * - Unexpected prefixes, suffixes, and malformed version shapes + * - Non-zero exit codes and missing executable conditions + * - Protected contract surface (no legacy soroban fallback, no identity/config inspection) + */ + +export interface SupportedVersionFixture { + readonly rawOutput: string; + readonly expectedVersion: string; + readonly description: string; +} + +export interface UnsupportedVersionFixture { + readonly rawOutput: string; + readonly extractedVersion: string; + readonly description: string; +} + +export interface MalformedOutputFixture { + readonly rawOutput: string; + readonly description: string; +} + +export interface NonZeroExecutionFixture { + readonly status: number; + readonly stdout: string; + readonly stderr: string; + readonly description: string; +} + +export interface MissingExecutableFixture { + readonly error: Error; + readonly stderr: string; + readonly description: string; +} + +/** + * Fixtures that must produce status 'pass' and match the expected version. + */ +export const SUPPORTED_STELLAR_VERSION_FIXTURES: readonly SupportedVersionFixture[] = + [ + { + rawOutput: '27.1.0\n', + expectedVersion: '27.1.0', + description: 'exact minimum supported baseline version', + }, + { + rawOutput: '27.1.1\n', + expectedVersion: '27.1.1', + description: 'supported patch increment', + }, + { + rawOutput: '27.2.0\n', + expectedVersion: '27.2.0', + description: 'supported minor increment', + }, + { + rawOutput: '28.0.0\n', + expectedVersion: '28.0.0', + description: 'supported next major version', + }, + { + rawOutput: '28.1.4\n', + expectedVersion: '28.1.4', + description: 'supported newer major and minor combination', + }, + { + rawOutput: '29.0.0\n', + expectedVersion: '29.0.0', + description: 'supported future protocol 29 release', + }, + { + rawOutput: '100.0.0\n', + expectedVersion: '100.0.0', + description: 'supported triple-digit major release', + }, + { + rawOutput: '27.10.25\n', + expectedVersion: '27.10.25', + description: 'supported double-digit minor and patch segments', + }, + { + rawOutput: 'v27.1.0\n', + expectedVersion: '27.1.0', + description: 'supported version with leading v prefix', + }, + { + rawOutput: 'v28.0.0\n', + expectedVersion: '28.0.0', + description: 'newer major version with leading v prefix', + }, + { + rawOutput: ' 27.1.0\n', + expectedVersion: '27.1.0', + description: 'leading whitespace before semantic version', + }, + { + rawOutput: '27.1.0 \n', + expectedVersion: '27.1.0', + description: 'trailing whitespace before newline', + }, + { + rawOutput: '27.1.0\r\n', + expectedVersion: '27.1.0', + description: 'Windows CRLF line terminator', + }, + { + rawOutput: ' \t27.1.0\t \r\n', + expectedVersion: '27.1.0', + description: 'mixed tabs and spaces surrounding version', + }, + { + rawOutput: '\n\n27.1.0\n\n', + expectedVersion: '27.1.0', + description: 'multiple leading and trailing newlines', + }, + { + rawOutput: ' v27.1.0 \r\n', + expectedVersion: '27.1.0', + description: 'whitespace surrounding leading v version', + }, + { + rawOutput: '27.1.0-rc.1\n', + expectedVersion: '27.1.0', + description: 'prerelease release-candidate suffix', + }, + { + rawOutput: '27.1.0-beta.2\n', + expectedVersion: '27.1.0', + description: 'prerelease beta suffix', + }, + { + rawOutput: '27.1.0-alpha.1\n', + expectedVersion: '27.1.0', + description: 'prerelease alpha suffix', + }, + { + rawOutput: 'v28.0.0-preview.3\n', + expectedVersion: '28.0.0', + description: 'leading v with prerelease preview suffix', + }, + { + rawOutput: '27.1.0+build.42\n', + expectedVersion: '27.1.0', + description: 'semver build metadata suffix', + }, + { + rawOutput: '27.1.0-rc.1+sha.abcdef\n', + expectedVersion: '27.1.0', + description: 'combined prerelease and build metadata suffix', + }, + ]; + +/** + * Fixtures that parse as valid semver but fall below the >=27.1.0 baseline. + * Must produce status 'fail' and include the remediation notice. + */ +export const UNSUPPORTED_STELLAR_VERSION_FIXTURES: readonly UnsupportedVersionFixture[] = + [ + { + rawOutput: '27.0.0\n', + extractedVersion: '27.0.0', + description: 'version immediately below the 27.1.0 baseline', + }, + { + rawOutput: '27.0.9\n', + extractedVersion: '27.0.9', + description: 'highest patch of earlier minor release', + }, + { + rawOutput: '26.0.0\n', + extractedVersion: '26.0.0', + description: 'earlier protocol 26 major version', + }, + { + rawOutput: '20.0.0\n', + extractedVersion: '20.0.0', + description: 'early Soroban transition release', + }, + { + rawOutput: '0.1.0\n', + extractedVersion: '0.1.0', + description: 'legacy development release', + }, + { + rawOutput: 'v27.0.1\n', + extractedVersion: '27.0.1', + description: 'unsupported version with leading v', + }, + { + rawOutput: '27.0.0-rc.1\n', + extractedVersion: '27.0.0', + description: 'unsupported version with prerelease tag', + }, + ]; + +/** + * Fixtures that cannot be parsed as valid Stellar CLI versions. + * Must produce status 'fail' with message 'Stellar CLI returned an unrecognized version.' + */ +export const MALFORMED_STELLAR_OUTPUT_FIXTURES: readonly MalformedOutputFixture[] = + [ + { + rawOutput: '27\n', + description: 'single major number without minor or patch', + }, + { + rawOutput: '27.1\n', + description: 'two-part version without patch segment', + }, + { + rawOutput: 'v27\n', + description: 'leading v with single major number', + }, + { + rawOutput: 'v27.1\n', + description: 'leading v with missing patch segment', + }, + { + rawOutput: '27.\n', + description: 'trailing dot after major segment', + }, + { + rawOutput: '27.1.\n', + description: 'trailing dot after minor segment', + }, + { + rawOutput: '27.x.1\n', + description: 'alphabetic wildcard in minor position', + }, + { + rawOutput: 'vA.B.C\n', + description: 'non-numeric alphabetic placeholders', + }, + { + rawOutput: '27.1.0a\n', + description: 'alphabetic character appended directly to patch', + }, + { + rawOutput: '27.1.0.0\n', + description: 'four-part quad versioning', + }, + { + rawOutput: 'stellar 27.1.0\n', + description: 'binary name prefix before version', + }, + { + rawOutput: 'stellar-cli 27.1.0\n', + description: 'hyphenated binary prefix before version', + }, + { + rawOutput: 'version 27.1.0\n', + description: 'word prefix before version', + }, + { + rawOutput: 'Stellar CLI v27.1.0\n', + description: 'full sentence prefix before version', + }, + { + rawOutput: '27.1.0 (built 2026-01-01)\n', + description: 'unexpected trailing parenthetical build details', + }, + { + rawOutput: '27.1.0 extra-tokens\n', + description: 'unexpected trailing words after version', + }, + { + rawOutput: 'stellar-cli unknown\n', + description: 'unrecognized keyword string', + }, + { + rawOutput: 'error: unknown command\n', + description: 'error message string output to stdout', + }, + { + rawOutput: '', + description: 'completely empty stdout', + }, + { + rawOutput: ' \t \r\n', + description: 'whitespace and newline only stdout', + }, + { + rawOutput: '{"version": "27.1.0"}\n', + description: 'JSON structured object output', + }, + { + rawOutput: 'version: 27.1.0\n', + description: 'YAML structured key-value output', + }, + { + rawOutput: '502 Bad Gateway\n', + description: 'HTML markup error output', + }, + ]; + +/** + * Fixtures for process execution returning non-zero exit codes. + * Must produce status 'fail' with message 'Stellar CLI is unavailable or could not be executed.' + */ +export const NON_ZERO_EXECUTION_FIXTURES: readonly NonZeroExecutionFixture[] = [ + { + status: 1, + stdout: '', + stderr: 'error: generic process failure\n', + description: 'exit code 1 general error', + }, + { + status: 2, + stdout: '', + stderr: 'error: unrecognized option --only-version\n', + description: 'exit code 2 CLI usage / flag failure', + }, + { + status: 126, + stdout: '', + stderr: 'stellar: Permission denied\n', + description: 'exit code 126 command invoked cannot execute', + }, + { + status: 127, + stdout: '', + stderr: 'stellar: command not found\n', + description: 'exit code 127 command not found', + }, + { + status: 137, + stdout: '', + stderr: 'Killed\n', + description: 'exit code 137 terminated by SIGKILL', + }, + { + status: 1, + stdout: '27.1.0\n', + stderr: 'panic: runtime internal error\n', + description: + 'non-zero exit code takes precedence even when stdout contains valid version', + }, +]; + +/** + * Fixtures for process spawning failures (e.g. executable missing from PATH). + */ +export const MISSING_EXECUTABLE_FIXTURES: readonly MissingExecutableFixture[] = + [ + { + error: new Error('spawn stellar ENOENT'), + stderr: '', + description: 'executable missing from system PATH', + }, + { + error: new Error('spawn stellar EACCES'), + stderr: 'permission denied', + description: 'executable binary lacks execution permissions', + }, + { + error: new Error('spawn stellar ENOENT SECRET_PASSPHRASE=do-not-leak'), + stderr: 'SENSITIVE_KEY=do-not-leak', + description: + 'execution failure containing potential secrets to test redaction', + }, + ]; diff --git a/tests/stellar-diagnostic.test.ts b/tests/stellar-diagnostic.test.ts index 64c04c7..f04b165 100644 --- a/tests/stellar-diagnostic.test.ts +++ b/tests/stellar-diagnostic.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { RunCommand } from '../src/diagnostics/process.js'; import { createStellarDiagnostic } from '../src/diagnostics/stellar.js'; +import { + MALFORMED_STELLAR_OUTPUT_FIXTURES, + MISSING_EXECUTABLE_FIXTURES, + NON_ZERO_EXECUTION_FIXTURES, + SUPPORTED_STELLAR_VERSION_FIXTURES, + UNSUPPORTED_STELLAR_VERSION_FIXTURES, +} from './helpers/stellar-diagnostic-fixtures.js'; function result( status: number | null, @@ -17,67 +24,174 @@ function result( } describe('Stellar CLI diagnostic', () => { - it('uses the official machine-friendly version command', () => { - const calls: Array<{ executable: string; args: readonly string[] }> = []; - const execute: RunCommand = (executable, args) => { - calls.push({ executable, args }); - return result(0, '27.1.0\n'); - }; - - const diagnostic = createStellarDiagnostic(execute).run(); - - expect(calls).toEqual([ - { executable: 'stellar', args: ['version', '--only-version'] }, - ]); - expect(diagnostic.status).toBe('pass'); - expect(diagnostic.message).toContain('27.1.0'); + describe('supported version output fixtures', () => { + it('uses the official machine-friendly version command', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + return result(0, '27.1.0\n'); + }; + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(calls).toEqual([ + { executable: 'stellar', args: ['version', '--only-version'] }, + ]); + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('27.1.0'); + }); + + it.each(SUPPORTED_STELLAR_VERSION_FIXTURES)( + 'accepts $description: "$rawOutput"', + ({ rawOutput, expectedVersion }) => { + const execute: RunCommand = () => result(0, rawOutput); + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.id).toBe('stellar'); + expect(diagnostic.label).toBe('Stellar CLI'); + expect(diagnostic.message).toBe( + `Supported version ${expectedVersion} detected.`, + ); + }, + ); }); - it('accepts a leading v and newer compatible versions', () => { - const execute: RunCommand = () => result(0, 'v28.0.0\n'); - - const diagnostic = createStellarDiagnostic(execute).run(); - - expect(diagnostic.status).toBe('pass'); - expect(diagnostic.message).toContain('28.0.0'); + describe('unsupported baseline version fixtures', () => { + it.each(UNSUPPORTED_STELLAR_VERSION_FIXTURES)( + 'fails $description: "$rawOutput"', + ({ rawOutput, extractedVersion }) => { + const execute: RunCommand = () => result(0, rawOutput); + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.id).toBe('stellar'); + expect(diagnostic.label).toBe('Stellar CLI'); + expect(diagnostic.message).toBe( + `Unsupported Stellar CLI version ${extractedVersion}.`, + ); + expect(diagnostic.remediation).toBe( + 'Upgrade to Stellar CLI >=27.1.0 before using StellarForge Stellar workflows.', + ); + }, + ); }); - it('fails versions below the supported Stellar baseline', () => { - const execute: RunCommand = () => result(0, '27.0.0\n'); - - const diagnostic = createStellarDiagnostic(execute).run(); - - expect(diagnostic.status).toBe('fail'); - expect(diagnostic.message).toContain('27.0.0'); - expect(diagnostic.remediation).toContain('>=27.1.0'); + describe('malformed version output fixtures', () => { + it.each(MALFORMED_STELLAR_OUTPUT_FIXTURES)( + 'safely classifies $description: "$rawOutput"', + ({ rawOutput }) => { + const execute: RunCommand = () => result(0, rawOutput); + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.id).toBe('stellar'); + expect(diagnostic.label).toBe('Stellar CLI'); + expect(diagnostic.message).toBe( + 'Stellar CLI returned an unrecognized version.', + ); + expect(diagnostic.remediation).toContain( + 'Verify `stellar version --only-version` works', + ); + }, + ); }); - it('fails when Stellar CLI is unavailable without leaking process details', () => { - const execute: RunCommand = () => - result( - null, - '', - 'SECRET_SEED=do-not-render', - new Error('spawn stellar ENOENT SECRET_SEED=do-not-render'), - ); - - const diagnostic = createStellarDiagnostic(execute).run(); - - expect(diagnostic.status).toBe('fail'); - expect(diagnostic.message).toBe( - 'Stellar CLI is unavailable or could not be executed.', + describe('non-zero execution exit codes', () => { + it.each(NON_ZERO_EXECUTION_FIXTURES)( + 'fails on $description', + ({ status, stdout, stderr }) => { + const execute: RunCommand = () => result(status, stdout, stderr); + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.id).toBe('stellar'); + expect(diagnostic.message).toBe( + 'Stellar CLI is unavailable or could not be executed.', + ); + expect(diagnostic.remediation).toContain( + 'https://developers.stellar.org/docs/tools/cli/install-cli', + ); + }, ); - expect(JSON.stringify(diagnostic)).not.toContain('SECRET_SEED'); }); - it('fails malformed version output', () => { - const execute: RunCommand = () => result(0, 'stellar-cli unknown\n'); - - const diagnostic = createStellarDiagnostic(execute).run(); - - expect(diagnostic.status).toBe('fail'); - expect(diagnostic.message).toBe( - 'Stellar CLI returned an unrecognized version.', + describe('missing executable and execution error fixtures', () => { + it.each(MISSING_EXECUTABLE_FIXTURES)( + 'remains actionable for $description', + ({ error, stderr }) => { + const execute: RunCommand = () => result(null, '', stderr, error); + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.id).toBe('stellar'); + expect(diagnostic.message).toBe( + 'Stellar CLI is unavailable or could not be executed.', + ); + expect(diagnostic.remediation).toContain( + 'Install the current Stellar CLI and ensure `stellar` is available on PATH.', + ); + expect(diagnostic.remediation).toContain( + 'https://developers.stellar.org/docs/tools/cli/install-cli', + ); + + // Verify process error and stderr details are not leaked into user-facing output + const serialized = JSON.stringify(diagnostic); + expect(serialized).not.toContain('SECRET_PASSPHRASE'); + expect(serialized).not.toContain('SENSITIVE_KEY'); + }, ); }); + + describe('protected surface and contract integrity', () => { + it('strictly preserves the official stellar executable contract without legacy soroban fallback', () => { + const executedCommands: string[] = []; + const execute: RunCommand = (executable) => { + executedCommands.push(executable); + return result(null, '', '', new Error(`spawn ${executable} ENOENT`)); + }; + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(executedCommands).toEqual(['stellar']); + expect(executedCommands).not.toContain('soroban'); + expect(diagnostic.status).toBe('fail'); + }); + + it('does not inspect wallet, identities, networks, or configuration keys', () => { + const recordedArguments: Array<{ + executable: string; + args: readonly string[]; + }> = []; + const execute: RunCommand = (executable, args) => { + recordedArguments.push({ executable, args }); + return result(0, '27.1.0\n'); + }; + + createStellarDiagnostic(execute).run(); + + expect(recordedArguments).toHaveLength(1); + const invocation = recordedArguments[0]; + expect(invocation).toBeDefined(); + if (!invocation) throw new Error('Expected invocation'); + expect(invocation.executable).toBe('stellar'); + expect(invocation.args).toEqual(['version', '--only-version']); + + // Ensure no inspection flags or subcommands were passed + const forbiddenInspectionTokens = [ + 'identity', + 'keys', + 'network', + 'config', + 'contract', + 'secret', + 'account', + ]; + for (const arg of invocation.args) { + for (const token of forbiddenInspectionTokens) { + expect(arg.toLowerCase()).not.toContain(token); + } + } + }); + }); });