diff --git a/src/diagnostics/stellar.ts b/src/diagnostics/stellar.ts index ea406ba..4ea5489 100644 --- a/src/diagnostics/stellar.ts +++ b/src/diagnostics/stellar.ts @@ -15,9 +15,9 @@ const STELLAR_CLI_INSTALL_URL = function parseStellarVersion(output: string): string | null { const value = output.trim(); - const match = - /^v?(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(value); - return match?.[1] ?? null; + // prettier-ignore + const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value); + return match ? match[1] + '.' + match[2] + '.' + match[3] : null; } export function createStellarDiagnostic( diff --git a/tests/stellar-diagnostic-failure-matrix.test.ts b/tests/stellar-diagnostic-failure-matrix.test.ts new file mode 100644 index 0000000..3d72243 --- /dev/null +++ b/tests/stellar-diagnostic-failure-matrix.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { RunCommand } from '../src/diagnostics/process.js'; +import { createStellarDiagnostic } from '../src/diagnostics/stellar.js'; + +interface CommandResultFixture { + readonly status: number | null; + readonly stdout?: string; + readonly stderr?: string; + readonly error?: Error; +} + +function runDiagnostic(fixture: CommandResultFixture) { + const execute: RunCommand = () => ({ + status: fixture.status, + stdout: fixture.stdout ?? '', + stderr: fixture.stderr ?? '', + ...(fixture.error === undefined ? {} : { error: fixture.error }), + }); + + return createStellarDiagnostic(execute).run(); +} + +describe('Stellar CLI diagnostic failure matrix', () => { + it.each([ + ['27.0.9', '27.0.9'], + ['26.0.0', '26.0.0'], + ['20.0.0', '20.0.0'], + ['0.1.0', '0.1.0'], + ['v27.0.1', '27.0.1'], + ['27.0.0-rc.1', '27.0.0'], + ])('rejects supported-floor predecessor %s', (output, extractedVersion) => { + const diagnostic = runDiagnostic({ status: 0, stdout: output }); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + `Unsupported Stellar CLI version ${extractedVersion}.`, + ); + expect(diagnostic.remediation).toContain('>=27.1.0'); + }); + + it.each([ + '{"version":"27.1.0"}', + 'version: 27.1.0', + '502 Bad Gateway', + ])('rejects structured or proxy-error output %s', (output) => { + const diagnostic = runDiagnostic({ status: 0, stdout: output }); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Stellar CLI returned an unrecognized version.', + ); + }); + + it.each([ + { status: 2, stderr: 'unrecognized option' }, + { status: 126, stderr: 'permission denied' }, + { status: 137, stderr: 'process killed' }, + { + status: 1, + stdout: '27.1.0', + stderr: 'panic: internal failure', + }, + ])('fails closed for non-zero execution result $status', (fixture) => { + const diagnostic = runDiagnostic(fixture); + + expect(diagnostic.status).toBe('fail'); + 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', + ); + }); + + it.each([ + new Error('spawn stellar ENOENT SECRET_PASSPHRASE=do-not-leak'), + new Error('spawn stellar EACCES SENSITIVE_KEY=do-not-leak'), + ])('does not expose executable errors or stderr details', (error) => { + const diagnostic = runDiagnostic({ + status: null, + stderr: 'SENSITIVE_KEY=do-not-leak', + error, + }); + const serialized = JSON.stringify(diagnostic); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Stellar CLI is unavailable or could not be executed.', + ); + expect(serialized).not.toContain('SECRET_PASSPHRASE'); + expect(serialized).not.toContain('SENSITIVE_KEY'); + expect(serialized).not.toContain('do-not-leak'); + }); +}); diff --git a/tests/stellar-diagnostic-semver.test.ts b/tests/stellar-diagnostic-semver.test.ts new file mode 100644 index 0000000..31651c4 --- /dev/null +++ b/tests/stellar-diagnostic-semver.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { RunCommand } from '../src/diagnostics/process.js'; +import { createStellarDiagnostic } from '../src/diagnostics/stellar.js'; + +function runVersion(output: string) { + const execute: RunCommand = () => ({ + status: 0, + stdout: output, + stderr: '', + }); + + return createStellarDiagnostic(execute).run(); +} + +describe('Stellar CLI semantic-version grammar', () => { + it.each([ + ['27.1.0', '27.1.0'], + ['v27.1.0', '27.1.0'], + ['27.1.0-rc.1', '27.1.0'], + ['27.1.0+build.42', '27.1.0'], + ['27.1.0-rc.1+sha.abcdef', '27.1.0'], + ['28.0.0-preview.3', '28.0.0'], + ['27.10.25', '27.10.25'], + ['100.0.0', '100.0.0'], + ['27.1.0-0', '27.1.0'], + ['27.1.0+001', '27.1.0'], + ])('accepts valid SemVer output %s', (output, expectedVersion) => { + const diagnostic = runVersion(output); + + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toBe( + `Supported version ${expectedVersion} detected.`, + ); + }); + + it.each([ + '027.1.0', + '9007199254740992.0.0', + '27.01.0', + '27.1.00', + '27.1.0-', + '27.1.0+', + '27.1.0-..', + '27.1.0-alpha..beta', + '27.1.0+build..7', + '27.1.0-01', + '27.1.0-alpha.01', + '27.1.0-alpha+', + '27.1.0+build+extra', + '27.1.0a', + '27.1.0.0', + '27.1.0 (built 2026-01-01)', + '27.1.0 extra-tokens', + 'vA.B.C', + ])('rejects malformed SemVer output %s', (output) => { + const diagnostic = runVersion(output); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Stellar CLI returned an unrecognized version.', + ); + }); +});