From 1a83ca2ecbfb457bc0cac4094d730552091dac27 Mon Sep 17 00:00:00 2001 From: feyishola Date: Sat, 17 Oct 2026 20:12:32 +0100 Subject: [PATCH] test: add deployment security and CLI usage-error regression suites - SW-090: redact secret-like subprocess output in the process supervisor, buffering partial lines so secrets split across chunks are still caught, and test that deploy stdout/stderr never reflects secret fixtures while failures stay actionable. - SW-093: add an offline umbrella suite covering the network, source, process and output boundaries of the real deploy path together, asserting every spawned invocation targets Testnet only. - SW-094: report unknown root commands as unknown commands with a typo suggestion and the registered command inventory instead of an "excess arguments" error and a full help dump. - SW-095: give subcommands the root's exit override and remediation so unknown options and other usage errors exit 2 with a concise hint. Closes #190, closes #193, closes #194, closes #195 Co-Authored-By: Claude Opus 5.5 --- .../concise-usage-errors-redacted-output.md | 5 + src/commands/root.ts | 43 +- src/dev/orchestrator.ts | 66 ++- src/output/index.ts | 1 + src/output/redaction.ts | 20 + tests/deploy-output-secrets.test.ts | 193 +++++++++ tests/deployment-security.test.ts | 376 ++++++++++++++++++ tests/executable.test.ts | 23 +- tests/helpers/fake-child.ts | 61 +++ tests/helpers/index.ts | 15 + tests/helpers/root-command.ts | 52 +++ tests/helpers/secret-fixtures.ts | 33 ++ tests/redaction.test.ts | 41 ++ tests/unknown-command.test.ts | 106 +++++ tests/unknown-option.test.ts | 93 +++++ 15 files changed, 1107 insertions(+), 21 deletions(-) create mode 100644 .changeset/concise-usage-errors-redacted-output.md create mode 100644 src/output/redaction.ts create mode 100644 tests/deploy-output-secrets.test.ts create mode 100644 tests/deployment-security.test.ts create mode 100644 tests/helpers/fake-child.ts create mode 100644 tests/helpers/root-command.ts create mode 100644 tests/helpers/secret-fixtures.ts create mode 100644 tests/redaction.test.ts create mode 100644 tests/unknown-command.test.ts create mode 100644 tests/unknown-option.test.ts diff --git a/.changeset/concise-usage-errors-redacted-output.md b/.changeset/concise-usage-errors-redacted-output.md new file mode 100644 index 0000000..a18bb82 --- /dev/null +++ b/.changeset/concise-usage-errors-redacted-output.md @@ -0,0 +1,5 @@ +--- +'@diginodes/stellarforge-cli': patch +--- + +Report unknown commands as `unknown command` with a suggestion and the available command list, map subcommand usage errors (such as unknown options) to the validation exit code `2` with a one-line `--help` hint, and redact secret-like values (Stellar secret keys, seed phrases, tokens, passwords) from supervised subprocess output, including values split across output chunks. diff --git a/src/commands/root.ts b/src/commands/root.ts index 0e01a18..b6cab2d 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -6,21 +6,54 @@ import { createDoctorCommand } from './doctor.js'; import { createNewCommand } from './new.js'; import { createTestCommand } from './test.js'; +// Commander implements `unknownCommand()` publicly but omits it from typings. +interface UnknownCommandReporter { + unknownCommand(): never; +} + +function applyUsageErrorHandling(command: Command, remediation: string): void { + command + .exitOverride() + .showHelpAfterError(remediation) + .showSuggestionAfterError(true); +} + export function createRootCommand(version = resolveCliVersion()): Command { - return new Command() + const program = new Command() .name('stellarforge') .description( 'Open infrastructure for building production-ready Stellar applications.', ) .usage('[options] [command]') .version(version) - .exitOverride() - .showHelpAfterError() - .showSuggestionAfterError(true) - .action(() => undefined) + // Operands reach the action so unknown commands are reported as such + // rather than as excess arguments. + .allowExcessArguments() + .action((_options: unknown, command: Command) => { + if (command.args.length > 0) { + (command as unknown as UnknownCommandReporter).unknownCommand(); + } + }) .addCommand(createDoctorCommand()) .addCommand(createNewCommand()) .addCommand(createDevCommand()) .addCommand(createTestCommand()) .addCommand(createDeployCommand()); + + const commandNames = program.commands.map((command) => command.name()); + applyUsageErrorHandling( + program, + `Available commands: ${commandNames.join(', ')}.\nRun 'stellarforge --help' for usage.`, + ); + + // `addCommand` does not inherit parent settings, so subcommand usage errors + // must be configured explicitly to share the root's exit-code mapping. + for (const command of program.commands) { + applyUsageErrorHandling( + command, + `Run 'stellarforge ${command.name()} --help' for usage.`, + ); + } + + return program; } diff --git a/src/dev/orchestrator.ts b/src/dev/orchestrator.ts index 27f916c..0fa1912 100644 --- a/src/dev/orchestrator.ts +++ b/src/dev/orchestrator.ts @@ -1,11 +1,13 @@ import { spawn } from 'node:child_process'; import { SubprocessCliError, ValidationCliError } from '../errors/errors.js'; +import { redactSecretLikeText } from '../output/redaction.js'; import { TerminalOutput } from '../output/terminal.js'; import { buildSafeChildEnvironment } from './environment.js'; import type { DevProcessSpec } from './project.js'; interface ManagedStream { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown; + on(event: 'end', listener: () => void): unknown; } export interface ManagedChildProcess { @@ -48,24 +50,54 @@ function defaultSpawnProcess( }) as unknown as ManagedChildProcess; } -function writePrefixedLines( +const MAX_BUFFERED_LINE_LENGTH = 64 * 1024; + +interface PrefixedLineWriter { + write(chunk: Buffer | string): void; + flush(): void; +} + +// Child output is untrusted: buffer partial lines so secret-like values split +// across chunks are still redacted as a whole before reaching the terminal. +function createPrefixedLineWriter( output: TerminalOutput, label: string, - chunk: Buffer | string, isError: boolean, -): void { - for (const line of String(chunk).split(/\r?\n/)) { +): PrefixedLineWriter { + let pending = ''; + + const emit = (line: string): void => { if (line.length === 0) { - continue; + return; } - const message = `[${label}] ${line}`; + const message = `[${label}] ${redactSecretLikeText(line)}`; if (isError) { output.warning(message); } else { output.info(message); } - } + }; + + return { + write(chunk) { + const lines = `${pending}${String(chunk)}`.split(/\r?\n/); + pending = lines.pop() ?? ''; + + for (const line of lines) { + emit(line); + } + + if (pending.length > MAX_BUFFERED_LINE_LENGTH) { + emit(pending); + pending = ''; + } + }, + flush() { + emit(pending); + pending = ''; + }, + }; } export async function superviseDevProcesses( @@ -136,16 +168,26 @@ export async function superviseDevProcesses( } children.push(child); - child.stdout.on('data', (chunk) => - writePrefixedLines(output, spec.label, chunk, false), - ); - child.stderr.on('data', (chunk) => - writePrefixedLines(output, spec.label, chunk, true), + const stdoutWriter = createPrefixedLineWriter( + output, + spec.label, + false, ); + const stderrWriter = createPrefixedLineWriter(output, spec.label, true); + const flushOutput = (): void => { + stdoutWriter.flush(); + stderrWriter.flush(); + }; + child.stdout.on('data', (chunk) => stdoutWriter.write(chunk)); + child.stdout.on('end', () => stdoutWriter.flush()); + child.stderr.on('data', (chunk) => stderrWriter.write(chunk)); + child.stderr.on('end', () => stderrWriter.flush()); child.once('error', (error) => { + flushOutput(); fail(`Development process '${spec.label}' failed to start.`, error); }); child.once('exit', (code, signal) => { + flushOutput(); remaining -= 1; if (!shuttingDown && code !== 0) { diff --git a/src/output/index.ts b/src/output/index.ts index a800a3d..a55d565 100644 --- a/src/output/index.ts +++ b/src/output/index.ts @@ -1,3 +1,4 @@ +export { redactSecretLikeText, REDACTED_PLACEHOLDER } from './redaction.js'; export { sanitizeTerminalText, TerminalOutput, diff --git a/src/output/redaction.ts b/src/output/redaction.ts new file mode 100644 index 0000000..21a65f4 --- /dev/null +++ b/src/output/redaction.ts @@ -0,0 +1,20 @@ +export const REDACTED_PLACEHOLDER = '[REDACTED]'; + +const STELLAR_SECRET_STRKEY = /\bS[A-Z2-7]{55}\b/g; +const SENSITIVE_ASSIGNMENT = + /((?:secret(?:[ _-]?key)?|private[ _-]?key|seed(?:[ _-]?phrase)?|mnemonic|password|passphrase|(?:api|auth|access)?[_-]?token)["']?\s*[:=]\s*)\S.*$/gi; +const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi; + +/** + * Replaces secret-like material in a single line of untrusted text. + * + * Redaction is intentionally conservative: once a sensitive label such as + * `secret:` or `mnemonic=` is seen, the remainder of the line is withheld, + * because multi-word values such as seed phrases cannot be bounded reliably. + */ +export function redactSecretLikeText(line: string): string { + return line + .replace(STELLAR_SECRET_STRKEY, REDACTED_PLACEHOLDER) + .replace(BEARER_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`) + .replace(SENSITIVE_ASSIGNMENT, `$1${REDACTED_PLACEHOLDER}`); +} diff --git a/tests/deploy-output-secrets.test.ts b/tests/deploy-output-secrets.test.ts new file mode 100644 index 0000000..701487e --- /dev/null +++ b/tests/deploy-output-secrets.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import type { DevProcessSpec } from '../src/dev/project.js'; +import { + EXIT_CODES, + runWithCliErrorBoundary, + SubprocessCliError, +} from '../src/errors/index.js'; +import { + createCapturedTerminalOutput, + FakeChildProcess, + SECRET_FIXTURE_LINES, + SECRET_FIXTURE_VALUES, + SECRET_STRKEY_FIXTURE, +} from './helpers/index.js'; + +const DEPLOY_SPEC: DevProcessSpec = { + label: 'stellar:deploy', + command: 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ], + cwd: '/workspace', +}; +const DEPLOY_ARGS = [ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'deployer', +]; + +async function runDeploy( + child: FakeChildProcess, + drive: (child: FakeChildProcess) => Promise, +) { + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolveConfig: (_cwd, overrides) => overrides, + resolvePlan: () => [DEPLOY_SPEC], + spawnProcess: () => child, + }); + + const exitCode = runWithCliErrorBoundary(async () => { + await command.parseAsync(DEPLOY_ARGS); + }, captured.output); + await drive(child); + + return { exitCode: await exitCode, captured }; +} + +function expectNoSecrets(text: string): void { + for (const secret of SECRET_FIXTURE_VALUES) { + expect(text).not.toContain(secret); + } +} + +describe('deploy stdout/stderr secret non-reflection', () => { + it('redacts secret-like stdout and stderr lines on success', async () => { + const { exitCode, captured } = await runDeploy( + new FakeChildProcess(), + (child) => + child.finish(0, { + stdout: [...SECRET_FIXTURE_LINES.map((line) => `${line}\n`)], + stderr: [...SECRET_FIXTURE_LINES.map((line) => `${line}\n`)], + }), + ); + + expect(exitCode).toBe(EXIT_CODES.success); + expectNoSecrets(captured.stdoutText()); + expectNoSecrets(captured.stderrText()); + expect(captured.stdoutText()).toContain( + '[stellar:deploy] signing with [REDACTED] for tx', + ); + expect(captured.stderrText()).toContain('[stellar:deploy] Secret key:'); + }); + + it('redacts a secret split across subprocess output chunks', async () => { + const { captured } = await runDeploy(new FakeChildProcess(), (child) => + child.finish(0, { + stderr: [ + `leaked ${SECRET_STRKEY_FIXTURE.slice(0, 20)}`, + `${SECRET_STRKEY_FIXTURE.slice(20, 40)}`, + `${SECRET_STRKEY_FIXTURE.slice(40)} trailing\n`, + ], + }), + ); + + expect(captured.stderrText()).not.toContain( + SECRET_STRKEY_FIXTURE.slice(0, 20), + ); + expect(captured.stderrText()).toBe( + '[stellar:deploy] leaked [REDACTED] trailing\n', + ); + }); + + it('redacts an unterminated final line flushed on exit', async () => { + const { captured } = await runDeploy(new FakeChildProcess(), (child) => + child.finish(0, { stdout: [`Secret key: ${SECRET_STRKEY_FIXTURE}`] }), + ); + + expect(captured.stdoutText()).toContain( + '[stellar:deploy] Secret key: [REDACTED]', + ); + expectNoSecrets(captured.stdoutText()); + }); + + it('keeps an actionable, secret-free failure when deployment fails', async () => { + const { exitCode, captured } = await runDeploy( + new FakeChildProcess(), + (child) => + child.finish(1, { + stderr: [ + 'error: transaction simulation failed: insufficient balance\n', + ...SECRET_FIXTURE_LINES.map((line) => `${line}\n`), + ], + }), + ); + const stderr = captured.stderrText(); + + expect(exitCode).toBe(EXIT_CODES.subprocess); + expectNoSecrets(stderr); + expect(stderr).toContain( + '[stellar:deploy] error: transaction simulation failed: insufficient balance', + ); + expect(stderr).toContain( + "Development process 'stellar:deploy' exited unexpectedly with code 1.", + ); + }); + + it('does not surface secret-like spawn error details', async () => { + const child = new FakeChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolveConfig: (_cwd, overrides) => overrides, + resolvePlan: () => [DEPLOY_SPEC], + spawnProcess: () => child, + }); + let thrown: unknown; + + const running = command.parseAsync(DEPLOY_ARGS).catch((error: unknown) => { + thrown = error; + }); + await child.fail( + new Error(`spawn failed: secret=${SECRET_STRKEY_FIXTURE}`), + ); + await running; + + expect(thrown).toBeInstanceOf(SubprocessCliError); + const exitCode = await runWithCliErrorBoundary(() => { + throw thrown; + }, captured.output); + + expect(exitCode).toBe(EXIT_CODES.subprocess); + expectNoSecrets(captured.stdoutText() + captured.stderrText()); + expect(captured.stderrText()).toContain( + "Development process 'stellar:deploy' failed to start.", + ); + }); + + it('does not surface secret-like errors thrown while starting the process', async () => { + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolveConfig: (_cwd, overrides) => overrides, + resolvePlan: () => [DEPLOY_SPEC], + spawnProcess: () => { + throw new Error(`ENOENT stellar ${SECRET_STRKEY_FIXTURE}`); + }, + }); + + const exitCode = await runWithCliErrorBoundary(async () => { + await command.parseAsync(DEPLOY_ARGS); + }, captured.output); + + expect(exitCode).toBe(EXIT_CODES.subprocess); + expectNoSecrets(captured.stderrText()); + expect(captured.stderrText()).toContain( + "Failed to start development process 'stellar:deploy'.", + ); + }); +}); diff --git a/tests/deployment-security.test.ts b/tests/deployment-security.test.ts new file mode 100644 index 0000000..1bac8b5 --- /dev/null +++ b/tests/deployment-security.test.ts @@ -0,0 +1,376 @@ +import type * as childProcess from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import { EXIT_CODES, runWithCliErrorBoundary } from '../src/errors/index.js'; +import { + createCapturedTerminalOutput, + FakeChildProcess, + SECRET_FIXTURE_LINES, + SECRET_FIXTURE_VALUES, + SECRET_STRKEY_FIXTURE, + withTempDirectory, +} from './helpers/index.js'; + +// Umbrella regression suite for the deployment security boundaries: +// network (Testnet only), source (named aliases only), process (argument +// array, no shell, filtered environment) and output (no secret reflection). +// The real config loader, plan resolver and default spawner run end to end; +// only `node:child_process` is replaced, so the suite stays offline. + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + +const STELLAR_BINARY = process.platform === 'win32' ? 'stellar.exe' : 'stellar'; +const NON_TESTNET_NETWORKS = [ + 'mainnet', + 'MAINNET', + 'public', + 'pubnet', + 'futurenet', + 'local', + 'Testnet', + ' testnet', + 'testnet ', + 'testnet,mainnet', + '', +]; +const UNSAFE_SOURCES = [ + SECRET_STRKEY_FIXTURE, + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'alice --network mainnet', + '--network=mainnet', + '-n', + 'alice;rm -rf /', + '$(whoami)', + '`id`', + '../identity', + 'alice/bob', + 'seed phrase words', + 'a'.repeat(65), +]; + +interface DeployRun { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +interface DeployScenario { + readonly args: readonly string[]; + readonly config?: Record; + readonly cargo?: boolean; + readonly environment?: NodeJS.ProcessEnv; + readonly childExitCode?: number; + readonly childStdout?: string[]; + readonly childStderr?: string[]; +} + +async function runDeployScenario( + scenario: DeployScenario, + root: string, +): Promise { + if (scenario.cargo ?? true) { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + } + if (scenario.config !== undefined) { + writeFileSync( + join(root, 'stellarforge.config.json'), + JSON.stringify(scenario.config), + ); + } + + spawnMock.mockImplementation(() => { + const child = new FakeChildProcess(); + void child.finish(scenario.childExitCode ?? 0, { + ...(scenario.childStdout === undefined + ? {} + : { stdout: scenario.childStdout }), + ...(scenario.childStderr === undefined + ? {} + : { stderr: scenario.childStderr }), + }); + return child; + }); + + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + environment: scenario.environment ?? { PATH: '/usr/bin' }, + }); + const exitCode = await runWithCliErrorBoundary(async () => { + await command.parseAsync(['node', 'deploy', ...scenario.args]); + }, captured.output); + + return { + exitCode, + stdout: captured.stdoutText(), + stderr: captured.stderrText(), + }; +} + +function deploy(scenario: DeployScenario): Promise { + return withTempDirectory((root) => runDeployScenario(scenario, root)); +} + +function spawnedArgs(): readonly string[] { + expect(spawnMock).toHaveBeenCalledTimes(1); + return spawnMock.mock.calls[0]?.[1] as readonly string[]; +} + +function expectNoSecrets(text: string): void { + for (const secret of SECRET_FIXTURE_VALUES) { + expect(text).not.toContain(secret); + } +} + +beforeEach(() => { + spawnMock.mockReset(); +}); + +afterEach(() => { + // Every spawned invocation, in every scenario, must target Testnet only. + for (const call of spawnMock.mock.calls) { + const args = call[1] as readonly string[]; + expect(args.filter((arg) => arg === '--network')).toHaveLength(1); + expect(args[args.indexOf('--network') + 1]).toBe('testnet'); + expect(args.join(' ')).not.toMatch(/mainnet|futurenet|pubnet|public/i); + } +}); + +describe('deployment security regression suite', () => { + describe('network boundary: Mainnet automation remains impossible', () => { + it.each(NON_TESTNET_NETWORKS)( + 'rejects CLI network %j before any subprocess starts', + async (network) => { + const result = await deploy({ + args: ['--network', network, '--source', 'deployer'], + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(spawnMock).not.toHaveBeenCalled(); + expect(result.stdout).not.toContain('Deploying'); + }, + ); + + it.each(['mainnet', 'futurenet'])( + 'rejects configured network %j before any subprocess starts', + async (network) => { + const result = await deploy({ + args: ['--source', 'deployer'], + config: { version: 1, network }, + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain( + 'only the explicit `--network testnet`', + ); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); + + it('rejects a CLI Mainnet override of a Testnet configuration', async () => { + const result = await deploy({ + args: ['--network', 'mainnet', '--source', 'deployer'], + config: { version: 1, network: 'testnet', identity: 'deployer' }, + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('requires an explicit network instead of defaulting', async () => { + const result = await deploy({ args: ['--source', 'deployer'] }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain('Deployment network is required.'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('ignores Mainnet hints in the environment and pins --network testnet', async () => { + const result = await deploy({ + args: ['--network', 'testnet', '--source', 'deployer'], + environment: { + PATH: '/usr/bin', + STELLAR_NETWORK: 'mainnet', + STELLAR_NETWORK_PASSPHRASE: + 'Public Global Stellar Network ; September 2015', + }, + }); + + expect(result.exitCode).toBe(EXIT_CODES.success); + expect(spawnedArgs()).toEqual([ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ]); + }); + }); + + describe('source boundary: only named identity aliases are accepted', () => { + it.each(UNSAFE_SOURCES)( + 'rejects CLI source %j without echoing it or spawning', + async (source) => { + const result = await deploy({ + args: ['--network', 'testnet', '--source', source], + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(spawnMock).not.toHaveBeenCalled(); + expect(result.stderr).toContain('named Stellar CLI identity alias'); + if (source.length > 2) { + expect(result.stderr).not.toContain(source); + } + }, + ); + + it('rejects a raw secret identity from configuration without echoing it', async () => { + const result = await deploy({ + args: ['--network', 'testnet'], + config: { version: 1, identity: SECRET_STRKEY_FIXTURE }, + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).not.toContain(SECRET_STRKEY_FIXTURE); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it.each(['secretKey', 'private_key', 'seed', 'mnemonic'])( + 'rejects configuration secret field %j without echoing its value', + async (field) => { + const result = await deploy({ + args: ['--network', 'testnet', '--source', 'deployer'], + config: { version: 1, [field]: SECRET_STRKEY_FIXTURE }, + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain('must not contain secret material'); + expect(result.stderr).not.toContain(SECRET_STRKEY_FIXTURE); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); + + it('requires an explicit identity instead of defaulting', async () => { + const result = await deploy({ args: ['--network', 'testnet'] }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain('Deployment identity is required.'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + + describe('process boundary: argument array, no shell, filtered environment', () => { + it('spawns the Stellar CLI with exact arguments and safe options', () => + withTempDirectory(async (root) => { + const result = await runDeployScenario( + { + args: ['--network', 'testnet', '--source', 'deployer'], + environment: { + PATH: '/usr/bin', + HOME: '/home/fixture', + STELLAR_NETWORK: 'testnet', + STELLAR_SECRET_KEY: SECRET_STRKEY_FIXTURE, + STELLAR_ACCOUNT: 'deployer', + SOROBAN_SECRET_KEY: SECRET_STRKEY_FIXTURE, + AWS_SECRET_ACCESS_KEY: 'fixture-aws-secret', + NPM_TOKEN: 'fixture-npm-token', + }, + }, + root, + ); + + expect(result.exitCode).toBe(EXIT_CODES.success); + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { cwd: string; env: NodeJS.ProcessEnv; shell: boolean }, + ]; + expect(command).toBe(STELLAR_BINARY); + expect(args).toEqual([ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ]); + expect(options.shell).toBe(false); + expect(options.cwd).toBe(root); + expect(options.env).toEqual({ + PATH: '/usr/bin', + HOME: '/home/fixture', + STELLAR_NETWORK: 'testnet', + }); + })); + + it('does not spawn for an unsupported project layout', async () => { + const result = await deploy({ + args: ['--network', 'testnet', '--source', 'deployer'], + cargo: false, + }); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain('No Cargo.toml workspace was found'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('rejects a non-file configuration path before spawning', () => + withTempDirectory(async (root) => { + mkdirSync(join(root, 'stellarforge.config.json')); + const result = await runDeployScenario( + { args: ['--network', 'testnet', '--source', 'deployer'] }, + root, + ); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(spawnMock).not.toHaveBeenCalled(); + })); + }); + + describe('output boundary: secret-like subprocess output is not reflected', () => { + it('redacts secrets on both streams of a successful deployment', async () => { + const lines = SECRET_FIXTURE_LINES.map((line) => `${line}\n`); + const result = await deploy({ + args: ['--network', 'testnet', '--source', 'deployer'], + childStdout: [...lines, 'CDEPLOYEDCONTRACTFIXTURE\n'], + childStderr: lines, + }); + + expect(result.exitCode).toBe(EXIT_CODES.success); + expectNoSecrets(result.stdout); + expectNoSecrets(result.stderr); + expect(result.stdout).toContain( + '[stellar:deploy] CDEPLOYEDCONTRACTFIXTURE', + ); + }); + + it('keeps an actionable subprocess failure without reflecting secrets', async () => { + const result = await deploy({ + args: ['--network', 'testnet', '--source', 'deployer'], + childExitCode: 1, + childStderr: [ + `error: account not found\nsecret: ${SECRET_STRKEY_FIXTURE}\n`, + ], + }); + + expect(result.exitCode).toBe(EXIT_CODES.subprocess); + expectNoSecrets(result.stderr); + expect(result.stderr).toContain( + '[stellar:deploy] error: account not found', + ); + expect(result.stderr).toContain( + "Development process 'stellar:deploy' exited unexpectedly with code 1.", + ); + }); + }); +}); diff --git a/tests/executable.test.ts b/tests/executable.test.ts index e621f4c..d1909d9 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -85,8 +85,10 @@ describe('StellarForge CLI executable', () => { const result = runBuiltCli(['--definitely-unknown']); expect(result.status).toBe(2); - expect(result.stderr).toContain('error: unknown option'); - expect(result.stderr).toContain('Usage: stellarforge [options]'); + expect(result.stderr).toContain( + "error: unknown option '--definitely-unknown'", + ); + expect(result.stderr).toContain("Run 'stellarforge --help' for usage."); expect(result.stderr).not.toContain( 'StellarForge encountered an unexpected error.', ); @@ -96,10 +98,23 @@ describe('StellarForge CLI executable', () => { const result = runBuiltCli(['not-a-command']); expect(result.status).toBe(2); - expect(result.stderr).toContain('error:'); - expect(result.stderr).toContain('Usage: stellarforge [options]'); + expect(result.stderr).toContain("error: unknown command 'not-a-command'"); + expect(result.stderr).toContain( + 'Available commands: doctor, new, dev, test, deploy.', + ); expect(result.stderr).not.toContain( 'StellarForge encountered an unexpected error.', ); }); + + it('maps an unknown subcommand option to the validation exit code', () => { + const result = runBuiltCli(['deploy', '--bogus']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe( + "error: unknown option '--bogus'\n" + + "Run 'stellarforge deploy --help' for usage.\n", + ); + }); }); diff --git a/tests/helpers/fake-child.ts b/tests/helpers/fake-child.ts new file mode 100644 index 0000000..4fa58be --- /dev/null +++ b/tests/helpers/fake-child.ts @@ -0,0 +1,61 @@ +import { PassThrough } from 'node:stream'; +import type { ManagedChildProcess } from '../../src/dev/orchestrator.js'; + +/** Deterministic in-memory stand-in for a supervised child process. */ +export class FakeChildProcess implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly killedSignals: NodeJS.Signals[] = []; + #errorListener: ((error: Error) => void) | undefined; + #exitListener: + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; + + once(event: 'error', listener: (error: Error) => void): this; + once( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; + once( + event: 'error' | 'exit', + listener: + | ((error: Error) => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + if (event === 'error') { + this.#errorListener = listener as (error: Error) => void; + } else { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } + + return this; + } + + kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { + this.killedSignals.push(signal); + return true; + } + + /** Writes output, lets stream listeners observe it, then exits. */ + async finish( + code: number, + output: { readonly stdout?: string[]; readonly stderr?: string[] } = {}, + ): Promise { + await new Promise((resolve) => setImmediate(resolve)); + for (const chunk of output.stdout ?? []) { + this.stdout.write(chunk); + } + for (const chunk of output.stderr ?? []) { + this.stderr.write(chunk); + } + await new Promise((resolve) => setImmediate(resolve)); + this.#exitListener?.(code, null); + } + + async fail(error: Error): Promise { + await new Promise((resolve) => setImmediate(resolve)); + this.#errorListener?.(error); + } +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 32a9251..a6aabe2 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -13,3 +13,18 @@ export { withTempDirectory, type TempDirectoryFixture, } from './temp-directory.js'; +export { + commanderCode, + runRootCommand, + type RootCommandRun, +} from './root-command.js'; +export { FakeChildProcess } from './fake-child.js'; +export { + MNEMONIC_FIXTURE, + PASSWORD_FIXTURE, + SECOND_SECRET_STRKEY_FIXTURE, + SECRET_FIXTURE_LINES, + SECRET_FIXTURE_VALUES, + SECRET_STRKEY_FIXTURE, + TOKEN_FIXTURE, +} from './secret-fixtures.js'; diff --git a/tests/helpers/root-command.ts b/tests/helpers/root-command.ts new file mode 100644 index 0000000..0c6c06f --- /dev/null +++ b/tests/helpers/root-command.ts @@ -0,0 +1,52 @@ +import { CommanderError } from 'commander'; +import { createRootCommand } from '../../src/commands/root.js'; +import { handleCliError, type CliExitCode } from '../../src/errors/index.js'; +import { createCapturedTerminalOutput } from './captured-output.js'; + +export interface RootCommandRun { + readonly exitCode: CliExitCode; + readonly error: unknown; + readonly stdout: string; + readonly stderr: string; +} + +/** + * Parses user arguments through the real root command in-process, capturing + * Commander output for the root and every registered subcommand. + */ +export async function runRootCommand( + args: readonly string[], +): Promise { + const program = createRootCommand('1.2.3'); + const stdout: string[] = []; + const stderr: string[] = []; + + for (const command of [program, ...program.commands]) { + command.configureOutput({ + writeOut: (text) => stdout.push(text), + writeErr: (text) => stderr.push(text), + }); + } + + const captured = createCapturedTerminalOutput(); + let error: unknown; + let exitCode: CliExitCode = 0; + + try { + await program.parseAsync([...args], { from: 'user' }); + } catch (caught) { + error = caught; + exitCode = handleCliError(caught, captured.output); + } + + return { + exitCode, + error, + stdout: stdout.join('') + captured.stdoutText(), + stderr: stderr.join('') + captured.stderrText(), + }; +} + +export function commanderCode(error: unknown): string | undefined { + return error instanceof CommanderError ? error.code : undefined; +} diff --git a/tests/helpers/secret-fixtures.ts b/tests/helpers/secret-fixtures.ts new file mode 100644 index 0000000..cec814d --- /dev/null +++ b/tests/helpers/secret-fixtures.ts @@ -0,0 +1,33 @@ +/** + * Synthetic, non-functional secret-like fixtures. They match the shapes the + * CLI must never reflect but are not real keys for any network. + */ +export const SECRET_STRKEY_FIXTURE = + 'SC' + 'FIXTURE2SECRET7NOTREAL'.padEnd(54, 'A'); +export const SECOND_SECRET_STRKEY_FIXTURE = + 'SD' + 'ANOTHER2FIXTURE7SEED'.padEnd(54, 'B'); +export const MNEMONIC_FIXTURE = + 'fixture alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo'; +export const TOKEN_FIXTURE = 'tok_FIXTURE_DO_NOT_REFLECT_123'; +export const PASSWORD_FIXTURE = 'hunter2-FIXTURE-PASSWORD'; + +export const SECRET_FIXTURE_VALUES = [ + SECRET_STRKEY_FIXTURE, + SECOND_SECRET_STRKEY_FIXTURE, + MNEMONIC_FIXTURE, + TOKEN_FIXTURE, + PASSWORD_FIXTURE, +] as const; + +/** Subprocess lines that embed each fixture in a realistic shape. */ +export const SECRET_FIXTURE_LINES = [ + `Secret key: ${SECRET_STRKEY_FIXTURE}`, + `signing with ${SECOND_SECRET_STRKEY_FIXTURE} for tx`, + `seed phrase: ${MNEMONIC_FIXTURE}`, + `mnemonic=${MNEMONIC_FIXTURE}`, + `Authorization: Bearer ${TOKEN_FIXTURE}`, + `ACCESS_TOKEN=${TOKEN_FIXTURE}`, + `password = ${PASSWORD_FIXTURE}`, + `{"secret":"${SECRET_STRKEY_FIXTURE}"}`, + `{"password":"${PASSWORD_FIXTURE}"}`, +] as const; diff --git a/tests/redaction.test.ts b/tests/redaction.test.ts new file mode 100644 index 0000000..5c1f03e --- /dev/null +++ b/tests/redaction.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + REDACTED_PLACEHOLDER, + redactSecretLikeText, +} from '../src/output/index.js'; +import { + SECRET_FIXTURE_LINES, + SECRET_FIXTURE_VALUES, + SECRET_STRKEY_FIXTURE, +} from './helpers/index.js'; + +describe('secret-like text redaction', () => { + it.each(SECRET_FIXTURE_LINES)('redacts %j', (line) => { + const redacted = redactSecretLikeText(line); + + expect(redacted).toContain(REDACTED_PLACEHOLDER); + for (const secret of SECRET_FIXTURE_VALUES) { + expect(redacted).not.toContain(secret); + } + }); + + it('keeps the sensitive label so the line stays understandable', () => { + expect(redactSecretLikeText(`Secret key: ${SECRET_STRKEY_FIXTURE}`)).toBe( + `Secret key: ${REDACTED_PLACEHOLDER}`, + ); + expect(redactSecretLikeText('mnemonic=one two three')).toBe( + `mnemonic=${REDACTED_PLACEHOLDER}`, + ); + }); + + it.each([ + 'Contract deployed: CABC1234567890', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'error: insufficient balance for account alice', + 'Compiling soroban-sdk v22.0.0', + 'tokens remaining: 3', + 'Using network testnet', + ])('leaves non-secret line %j unchanged', (line) => { + expect(redactSecretLikeText(line)).toBe(line); + }); +}); diff --git a/tests/unknown-command.test.ts b/tests/unknown-command.test.ts new file mode 100644 index 0000000..9c41839 --- /dev/null +++ b/tests/unknown-command.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { createRootCommand } from '../src/commands/root.js'; +import { EXIT_CODES } from '../src/errors/index.js'; +import { commanderCode, runRootCommand } from './helpers/index.js'; + +const V1_COMMANDS = ['doctor', 'new', 'dev', 'test', 'deploy']; +const POST_V1_COMMANDS = [ + 'build', + 'init', + 'publish', + 'upgrade', + 'login', + 'mainnet', + 'plugins', +]; +const INVENTORY_LINE = `Available commands: ${V1_COMMANDS.join(', ')}.`; +const HELP_HINT = "Run 'stellarforge --help' for usage."; +const MAX_REMEDIATION_LINES = 4; + +describe('unknown-command remediation', () => { + it.each(['not-a-command', 'help-me', 'DEPLOY', 'mainnet', 'publish'])( + 'rejects %j as an unknown command with the validation exit code', + async (name) => { + const result = await runRootCommand([name]); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(commanderCode(result.error)).toBe('commander.unknownCommand'); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(`error: unknown command '${name}'`); + expect(result.stderr).toContain(INVENTORY_LINE); + expect(result.stderr).toContain(HELP_HINT); + }, + ); + + it('keeps the remediation concise instead of dumping full help', async () => { + const result = await runRootCommand(['not-a-command']); + const lines = result.stderr.trimEnd().split('\n'); + + expect(lines.length).toBeLessThanOrEqual(MAX_REMEDIATION_LINES); + expect(result.stderr).not.toContain('Usage:'); + expect(result.stderr).not.toContain('Options:'); + expect(result.stderr).toBe( + `error: unknown command 'not-a-command'\n${INVENTORY_LINE}\n${HELP_HINT}\n`, + ); + }); + + it.each([ + ['deplo', 'deploy'], + ['doctr', 'doctor'], + ['tset', 'test'], + ['nwe', 'new'], + ])('suggests the closest command for the typo %j', async (typo, command) => { + const result = await runRootCommand([typo]); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain(`error: unknown command '${typo}'`); + expect(result.stderr).toContain(`(Did you mean ${command}?)`); + }); + + it('does not run any command action for an unknown command', async () => { + const result = await runRootCommand(['not-a-command', 'deploy']); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(result.stderr).toContain("error: unknown command 'not-a-command'"); + expect(result.stderr).not.toContain('Deploying'); + }); + + it('derives the advertised inventory from registered commands only', () => { + const program = createRootCommand('1.2.3'); + const registered = program.commands.map((command) => command.name()); + + expect(registered).toEqual(V1_COMMANDS); + }); + + it('does not advertise post-v1 commands in remediation or help', async () => { + const result = await runRootCommand(['not-a-command']); + const help = createRootCommand('1.2.3').helpInformation(); + const advertised = result.stderr + .split('\n') + .find((line) => line.startsWith('Available commands:')); + + expect(advertised).toBe(INVENTORY_LINE); + for (const command of POST_V1_COMMANDS) { + expect(advertised).not.toMatch(new RegExp(`\\b${command}\\b`)); + expect(help).not.toMatch(new RegExp(`^\\s+${command}\\b`, 'm')); + } + }); + + it('preserves root semantics for no arguments, help, and version', async () => { + const bare = await runRootCommand([]); + expect(bare.exitCode).toBe(EXIT_CODES.success); + expect(bare.stdout).toBe(''); + expect(bare.stderr).toBe(''); + + const help = await runRootCommand(['--help']); + expect(help.exitCode).toBe(EXIT_CODES.success); + expect(help.stdout).toContain('Usage: stellarforge [options] [command]'); + for (const command of V1_COMMANDS) { + expect(help.stdout).toContain(command); + } + + const version = await runRootCommand(['--version']); + expect(version.exitCode).toBe(EXIT_CODES.success); + expect(version.stdout.trim()).toBe('1.2.3'); + }); +}); diff --git a/tests/unknown-option.test.ts b/tests/unknown-option.test.ts new file mode 100644 index 0000000..fdcdc4b --- /dev/null +++ b/tests/unknown-option.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { EXIT_CODES } from '../src/errors/index.js'; +import { commanderCode, runRootCommand } from './helpers/index.js'; + +const MAX_REMEDIATION_LINES = 4; + +describe('unknown-option remediation', () => { + it('rejects an unknown root option concisely with the inventory', async () => { + const result = await runRootCommand(['--definitely-unknown']); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(commanderCode(result.error)).toBe('commander.unknownOption'); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe( + "error: unknown option '--definitely-unknown'\n" + + 'Available commands: doctor, new, dev, test, deploy.\n' + + "Run 'stellarforge --help' for usage.\n", + ); + }); + + it.each([ + ['doctor', ['doctor', '--bogus']], + ['new', ['new', 'demo-app', '--bogus']], + ['dev', ['dev', '--bogus']], + ['test', ['test', '--bogus']], + ['deploy', ['deploy', '--bogus']], + ])( + 'rejects an unknown %s option consistently before running the command', + async (command, args) => { + const result = await runRootCommand(args); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(commanderCode(result.error)).toBe('commander.unknownOption'); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe( + "error: unknown option '--bogus'\n" + + `Run 'stellarforge ${command} --help' for usage.\n`, + ); + }, + ); + + it.each([ + [['deploy', '--netwrk', 'testnet'], '--netwrk', '--network'], + [['deploy', '--sourse', 'alice'], '--sourse', '--source'], + [['new', 'demo-app', '--templte', 'basic-app'], '--templte', '--template'], + ])( + 'suggests the closest option for %j', + async (args, unknown, suggestion) => { + const result = await runRootCommand(args); + const lines = result.stderr.trimEnd().split('\n'); + + expect(result.exitCode).toBe(EXIT_CODES.validation); + expect(lines[0]).toBe(`error: unknown option '${unknown}'`); + expect(lines).toContain(`(Did you mean ${suggestion}?)`); + expect(lines.length).toBeLessThanOrEqual(MAX_REMEDIATION_LINES); + }, + ); + + it('keeps subcommand remediation concise instead of dumping full help', async () => { + const result = await runRootCommand(['deploy', '--bogus']); + + expect(result.stderr).not.toContain('Usage:'); + expect(result.stderr).not.toContain('Options:'); + expect(result.stderr.trimEnd().split('\n').length).toBeLessThanOrEqual( + MAX_REMEDIATION_LINES, + ); + }); + + it('maps other subcommand usage errors to the validation exit code', async () => { + const missing = await runRootCommand(['new']); + expect(missing.exitCode).toBe(EXIT_CODES.validation); + expect(missing.stderr).toContain( + "missing required argument 'project-name'", + ); + expect(missing.stderr).toContain( + "Run 'stellarforge new --help' for usage.", + ); + + const excess = await runRootCommand(['doctor', 'extra']); + expect(excess.exitCode).toBe(EXIT_CODES.validation); + expect(excess.stderr).toContain("too many arguments for 'doctor'"); + }); + + it('preserves subcommand help as a successful exit', async () => { + const result = await runRootCommand(['deploy', '--help']); + + expect(result.exitCode).toBe(EXIT_CODES.success); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('Usage: stellarforge deploy [options]'); + expect(result.stdout).toContain('--network '); + expect(result.stdout).toContain('--source '); + }); +});