diff --git a/tests/deploy-argument-order.test.ts b/tests/deploy-argument-order.test.ts new file mode 100644 index 0000000..b9309ba --- /dev/null +++ b/tests/deploy-argument-order.test.ts @@ -0,0 +1,88 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveTestnetDeploymentPlan } from '../src/deployment/project.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { withTempDirectory } from './helpers/index.js'; + +const STELLAR_EXECUTABLE = + process.platform === 'win32' ? 'stellar.exe' : 'stellar'; + +function writeCargoWorkspace(root: string): void { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); +} + +describe('Stellar deploy argument plan', () => { + it('locks the exact executable and argument order', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect( + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'deployer', + }), + ).toEqual([ + { + label: 'stellar:deploy', + command: STELLAR_EXECUTABLE, + args: [ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ], + cwd: root, + }, + ]); + })); + + it('keeps the source alias and network as separate argv entries', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + const [spec] = resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'team-deployer', + }); + const args = spec?.args ?? []; + + expect(args).toEqual([ + 'contract', + 'deploy', + '--source-account', + 'team-deployer', + '--network', + 'testnet', + ]); + expect(args[3]).toBe('team-deployer'); + expect(args[5]).toBe('testnet'); + for (const arg of args) { + expect(arg).not.toMatch(/\s/); + } + })); + + it('rejects a non-Testnet network before building any argument plan', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'mainnet', + source: 'deployer', + }), + ).toThrow(ValidationCliError); + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'futurenet', + source: 'deployer', + }), + ).toThrow('only the explicit `--network testnet`'); + })); +}); diff --git a/tests/deploy-contract-id-output.test.ts b/tests/deploy-contract-id-output.test.ts new file mode 100644 index 0000000..4addd49 --- /dev/null +++ b/tests/deploy-contract-id-output.test.ts @@ -0,0 +1,110 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} from './helpers/index.js'; +import { MockChildProcess } from './helpers/mock-child-process.js'; + +const CONTRACT_ID = 'CMOCKCONTRACTID00000000000000000000000000000000000000'; + +const deployArgs = [ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'deployer', +]; + +function writeCargoWorkspace(root: string): void { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); +} + +describe('deploy contract-ID output handling', () => { + it('surfaces the mocked contract ID and completes successfully', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + spawnProcess: () => child, + }); + + const running = command.parseAsync(deployArgs); + child.stdout.write(`Deployed contract ID: ${CONTRACT_ID}\n`); + child.exitWith(0); + await running; + + expect(captured.stdoutText()).toContain( + `[stellar:deploy] Deployed contract ID: ${CONTRACT_ID}`, + ); + expect(captured.stderrText()).toBe(''); + })); + + it('does not let extra output change a successful exit', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + spawnProcess: () => child, + }); + + const running = command.parseAsync(deployArgs); + child.stdout.write(`Deployed contract ID: ${CONTRACT_ID}\n`); + child.stdout.write('note: additional informational line\n'); + child.stderr.write('warning: simulated non-fatal notice\n'); + child.stdout.write('error: text that is not fatal\n'); + child.exitWith(0); + await running; + + expect(captured.stdoutText()).toContain(CONTRACT_ID); + expect(captured.stderrText()).toContain('simulated non-fatal notice'); + })); + + it('treats error-looking output as non-fatal on a zero exit', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + spawnProcess: () => child, + }); + + const running = command.parseAsync(deployArgs); + child.stdout.write('error: mock warning line\n'); + child.exitWith(0); + + await expect(running).resolves.toBeDefined(); + })); + + it('completes deterministically when an empty deploy output is produced', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + spawnProcess: () => child, + }); + + const running = command.parseAsync(deployArgs); + child.exitWith(0); + await running; + + expect(captured.stdoutText()).toContain( + 'Deploying smart contract to Stellar Testnet.', + ); + expect(captured.stdoutText()).not.toContain('[stellar:deploy]'); + })); +}); diff --git a/tests/deploy-shell-boundary.test.ts b/tests/deploy-shell-boundary.test.ts new file mode 100644 index 0000000..ea9b345 --- /dev/null +++ b/tests/deploy-shell-boundary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { superviseDevProcesses } from '../src/dev/orchestrator.js'; +import type { DevProcessSpec } from '../src/dev/project.js'; +import { MockChildProcess } from './helpers/mock-child-process.js'; + +const hoisted = vi.hoisted(() => { + const state: { child: unknown } = { child: undefined }; + const spawn = vi.fn(() => state.child); + return { state, spawn }; +}); + +vi.mock('node:child_process', () => ({ spawn: hoisted.spawn })); + +const deploySpec: DevProcessSpec = { + label: 'stellar:deploy', + command: process.platform === 'win32' ? 'stellar.exe' : 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ], + cwd: process.cwd(), +}; + +describe('deploy spawn shell boundary', () => { + it('spawns Stellar with an argument array and shell: false', async () => { + const child = new MockChildProcess(); + hoisted.state.child = child; + + const running = superviseDevProcesses([deploySpec], { environment: {} }); + child.exitWith(0); + await running; + + expect(hoisted.spawn).toHaveBeenCalledTimes(1); + expect(hoisted.spawn).toHaveBeenCalledWith( + deploySpec.command, + deploySpec.args, + expect.objectContaining({ shell: false, cwd: deploySpec.cwd }), + ); + }); +}); diff --git a/tests/deploy-source-rejection.test.ts b/tests/deploy-source-rejection.test.ts new file mode 100644 index 0000000..0b1a6af --- /dev/null +++ b/tests/deploy-source-rejection.test.ts @@ -0,0 +1,136 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import { resolveTestnetDeploymentPlan } from '../src/deployment/project.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { withTempDirectory } from './helpers/index.js'; + +const SENTINEL = 'SENTINEL9'; + +const safeAliases = [ + 'deployer', + 'alice', + 'team-deployer', + 'team_deployer', + 'A1', + 'a'.repeat(64), +]; + +const unsafeSources = [ + 'S'.concat('A'.repeat(55)), + 'G'.concat('A'.repeat(55)), + 'seed phrase words', + '../identity', + '/etc/passwd', + 'alice/bob', + '.hidden', + '..', + '~/deployer', + 'C:\\Users\\deployer', + 'name;rm -rf /', + 'name&&deploy', + 'name|deploy', + 'name$(id)', + 'name`id`', + 'name>out', + 'name { + it.each(unsafeSources)('rejects unsafe source value %j', (source) => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source, + }), + ).toThrow(ValidationCliError); + })); + + it.each(sensitiveSources)( + 'does not echo rejected source value %j', + (source) => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + try { + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source, + }); + throw new Error('Expected source validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).not.toContain(SENTINEL); + } + }), + ); + + it.each(safeAliases)('accepts safe identity alias %j', (source) => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + const [spec] = resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source, + }); + + expect(spec?.args).toContain('--source-account'); + expect(spec?.args).toContain(source); + })); + + it('rejects an unsafe source before the deploy subprocess starts', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + let spawnCalled = false; + const command = createDeployCommand({ + cwd: () => root, + spawnProcess: () => { + spawnCalled = true; + throw new Error('The deploy subprocess must not start.'); + }, + }); + + await expect( + command.parseAsync([ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + '../escape-attempt', + ]), + ).rejects.toBeInstanceOf(ValidationCliError); + expect(spawnCalled).toBe(false); + })); +}); diff --git a/tests/deploy-subprocess-propagation.test.ts b/tests/deploy-subprocess-propagation.test.ts new file mode 100644 index 0000000..d7e34d2 --- /dev/null +++ b/tests/deploy-subprocess-propagation.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import type { DevProcessSpec } from '../src/dev/project.js'; +import { + EXIT_CODES, + SubprocessCliError, + runWithCliErrorBoundary, +} from '../src/errors/index.js'; +import { + createCapturedTerminalOutput, + type CapturedTerminalOutput, +} from './helpers/index.js'; +import { MockChildProcess } from './helpers/mock-child-process.js'; + +const DEPLOY_SPEC: DevProcessSpec = { + label: 'stellar:deploy', + command: 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ], + cwd: '/workspace', +}; + +const deployArgs = [ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'deployer', +]; + +function createDeploy( + child: MockChildProcess, + captured: CapturedTerminalOutput, +) { + return createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolvePlan: () => [DEPLOY_SPEC], + spawnProcess: () => child, + }); +} + +function takeRejection(running: Promise): Promise { + return running.then( + () => undefined, + (reason: unknown) => reason, + ); +} + +describe('deploy subprocess failure propagation', () => { + it('propagates a non-zero Stellar exit as a typed subprocess failure', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + + const running = command.parseAsync(deployArgs); + child.exitWith(2); + + const error = await takeRejection(running); + + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).exitCode).toBe(EXIT_CODES.subprocess); + expect((error as SubprocessCliError).userMessage).toBe( + "Development process 'stellar:deploy' exited unexpectedly with code 2.", + ); + }); + + it('reports signal termination without masking the failure category', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + + const running = command.parseAsync(deployArgs); + child.exitWith(null, 'SIGKILL'); + + const error = await takeRejection(running); + + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).userMessage).toContain( + 'after SIGKILL', + ); + }); + + it('preserves a start failure with the original cause', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + const startError = new Error('spawn stellar ENOENT'); + + const running = command.parseAsync(deployArgs); + child.fail(startError); + + const error = await takeRejection(running); + + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).cause).toBe(startError); + expect((error as SubprocessCliError).userMessage).toBe( + "Development process 'stellar:deploy' failed to start.", + ); + }); + + it('does not reflect secret-bearing output in the failure message', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + + const running = command.parseAsync(deployArgs); + child.stderr.write('seed=SENTINEL-SUBPROCESS-OUTPUT\n'); + child.exitWith(1); + + const error = await takeRejection(running); + + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).userMessage).not.toContain( + 'SENTINEL-SUBPROCESS-OUTPUT', + ); + }); + + it('sanitizes streamed subprocess output before it reaches the terminal', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + + const running = command.parseAsync(deployArgs); + child.stderr.write('error: \u001b[31mred\u001b[0m detail\n'); + child.exitWith(1); + await takeRejection(running); + + expect(captured.stderrText()).toContain( + '[stellar:deploy] error: red detail', + ); + expect(captured.stderrText()).not.toContain('\u001b'); + }); + + it('renders the subprocess failure with exit code 3 and a safe message', async () => { + const child = new MockChildProcess(); + const captured = createCapturedTerminalOutput(); + const command = createDeploy(child, captured); + + const exitCode = await runWithCliErrorBoundary(async () => { + const running = command.parseAsync(deployArgs); + child.exitWith(2); + await running; + }, captured.output); + + expect(exitCode).toBe(EXIT_CODES.subprocess); + expect(captured.stderrText()).toContain( + "Development process 'stellar:deploy' exited unexpectedly with code 2.", + ); + }); +}); diff --git a/tests/helpers/mock-child-process.ts b/tests/helpers/mock-child-process.ts new file mode 100644 index 0000000..cef1231 --- /dev/null +++ b/tests/helpers/mock-child-process.ts @@ -0,0 +1,53 @@ +import { PassThrough } from 'node:stream'; +import type { ManagedChildProcess } from '../../src/dev/orchestrator.js'; + +/** + * Deterministic in-memory stand-in for a spawned child process. + * + * Tests drive the process lifecycle explicitly through {@link exitWith} and + * {@link fail} instead of starting a real executable, so deploy and test + * workflows can be exercised without a Stellar network, identity, or secret. + */ +export class MockChildProcess implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + #exitListener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | undefined; + #errorListener: ((error: Error) => 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 === 'exit') { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } else { + this.#errorListener = listener as (error: Error) => void; + } + + return this; + } + + kill(): boolean { + return true; + } + + exitWith(code: number | null, signal: NodeJS.Signals | null = null): void { + this.#exitListener?.(code, signal); + } + + fail(error: Error): void { + this.#errorListener?.(error); + } +}