diff --git a/tests/configuration-reference-examples.test.ts b/tests/configuration-reference-examples.test.ts new file mode 100644 index 0000000..d143109 --- /dev/null +++ b/tests/configuration-reference-examples.test.ts @@ -0,0 +1,98 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + PROJECT_CONFIG_FILENAME, + resolveProjectConfig, +} from '../src/config/project.js'; +import { validateStellarForgeConfig } from '../src/config/schema.js'; +import { withTempDirectory } from './helpers/index.js'; + +const root = join(import.meta.dirname, '..'); +const configurationReference = readFileSync( + join(root, 'docs', 'reference', 'configuration.md'), + 'utf8', +); + +const JSON_BLOCK = /```json\n([\s\S]*?)```/g; +const jsonMatches = [...configurationReference.matchAll(JSON_BLOCK)]; +const jsonBlocks = jsonMatches.map((match) => (match[1] ?? '').trim()); + +function writeConfig(dir: string, value: unknown): void { + writeFileSync(join(dir, PROJECT_CONFIG_FILENAME), JSON.stringify(value)); +} + +describe('configuration reference examples', () => { + it('ships at least the schema and precedence examples', () => { + expect(jsonBlocks.length).toBeGreaterThanOrEqual(2); + }); + + it('parses and validates every documented JSON example', () => { + for (const block of jsonBlocks) { + const parsed: unknown = JSON.parse(block); + + expect(() => validateStellarForgeConfig(parsed)).not.toThrow(); + } + }); + + it('documents only supported schema fields in the schema example', () => { + const parsed = JSON.parse(jsonBlocks[0] ?? '') as Record; + + expect(Object.keys(parsed).sort()).toEqual([ + 'identity', + 'network', + 'version', + ]); + }); + + it('never places secret material inside a documented example', () => { + const combined = jsonBlocks.join('\n').toLowerCase(); + + for (const field of [ + 'privatekey', + 'secret', + 'seed', + 'mnemonic', + 'password', + 'passphrase', + 'token', + ]) { + expect(combined).not.toContain(field); + } + }); + + it('documents a precedence example that the resolver honours', () => + withTempDirectory((dir) => { + writeConfig(dir, { + version: 1, + network: 'testnet', + identity: 'team-deployer', + }); + + expect(resolveProjectConfig(dir)).toEqual({ + network: 'testnet', + identity: 'team-deployer', + }); + expect( + resolveProjectConfig(dir, { identity: 'release-deployer' }), + ).toEqual({ + network: 'testnet', + identity: 'release-deployer', + }); + })); + + it('documents the precedence order, missing default, and secret boundary', () => { + expect(configurationReference).toContain('explicit CLI option;'); + expect(configurationReference).toContain('project configuration;'); + expect(configurationReference).toContain('missing.'); + expect(configurationReference).toContain( + 'StellarForge does not silently default a missing network to Mainnet or any other network.', + ); + expect(configurationReference).toContain('does not accept raw secret keys'); + expect(configurationReference).toContain( + 'A configured network does not bypass command-specific restrictions', + ); + expect(configurationReference).not.toContain('defaults to Mainnet'); + expect(configurationReference).not.toContain('defaults to mainnet'); + }); +}); diff --git a/tests/configuration-secret-reflection.test.ts b/tests/configuration-secret-reflection.test.ts new file mode 100644 index 0000000..53dd238 --- /dev/null +++ b/tests/configuration-secret-reflection.test.ts @@ -0,0 +1,120 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, +} from '../src/config/project.js'; +import { + EXIT_CODES, + ValidationCliError, + runWithCliErrorBoundary, +} from '../src/errors/index.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} from './helpers/index.js'; + +const SECRET_FIELD_NAMES = [ + 'privateKey', + 'private_key', + 'private-key', + 'secretKey', + 'secret', + 'seed', + 'seedPhrase', + 'mnemonic', + 'password', + 'PASSWORD', + 'passphrase', + 'token', + 'apiToken', + 'authToken', + 'my_token', +]; + +const SECRET_MESSAGE = + 'StellarForge project configuration must not contain secret material.'; + +describe('configuration secret non-reflection', () => { + it.each(SECRET_FIELD_NAMES)( + 'rejects secret-bearing field %j without echoing its value', + (field) => + withTempDirectory((dir) => { + const secret = `SENTINEL-SECRET-${field}`; + writeFileSync( + join(dir, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, [field]: secret }), + ); + + try { + loadProjectConfig(dir); + throw new Error('Expected configuration validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + + const message = (error as Error).message; + expect(message).toContain('must not contain secret material'); + expect(message).not.toContain(secret); + } + }), + ); + + it('does not reflect secrets from malformed JSON', () => + withTempDirectory((dir) => { + writeFileSync( + join(dir, PROJECT_CONFIG_FILENAME), + '{"version": 1, "privateKey": "SENTINEL-MALFORMED-SECRET"', + ); + + try { + loadProjectConfig(dir); + throw new Error('Expected malformed JSON to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + + const message = (error as Error).message; + expect(message).toBe('stellarforge.config.json is not valid JSON.'); + expect(message).not.toContain('SENTINEL-MALFORMED-SECRET'); + } + })); + + it('rejects an unsupported field without echoing it', () => + withTempDirectory((dir) => { + writeFileSync( + join(dir, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, unsupportedField: 'SENTINEL-UNKNOWN' }), + ); + + try { + loadProjectConfig(dir); + throw new Error('Expected configuration validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + + const message = (error as Error).message; + expect(message).toContain('unsupported field'); + expect(message).not.toContain('unsupportedField'); + expect(message).not.toContain('SENTINEL-UNKNOWN'); + } + })); + + it('renders secret validation failures without leaking through the boundary', () => + withTempDirectory(async (dir) => { + const secret = 'SENTINEL-BOUNDARY-SECRET'; + writeFileSync( + join(dir, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, privateKey: secret }), + ); + + const captured = createCapturedTerminalOutput(); + const exitCode = await runWithCliErrorBoundary(() => { + loadProjectConfig(dir); + }, captured.output); + + expect(exitCode).toBe(EXIT_CODES.validation); + expect(captured.stdoutText()).toBe(''); + expect(captured.stderrText()).toContain(SECRET_MESSAGE); + expect(captured.stderrText()).not.toContain(secret); + })); +}); diff --git a/tests/deploy-missing-config-remediation.test.ts b/tests/deploy-missing-config-remediation.test.ts new file mode 100644 index 0000000..594f3e7 --- /dev/null +++ b/tests/deploy-missing-config-remediation.test.ts @@ -0,0 +1,208 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { createDeployCommand } from '../src/commands/deploy.js'; +import { PROJECT_CONFIG_FILENAME } from '../src/config/project.js'; +import type { ManagedChildProcess } from '../src/dev/orchestrator.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} from './helpers/index.js'; + +const NETWORK_REMEDIATION = + 'Deployment network is required. Pass `--network testnet` or set an explicit network in stellarforge.config.json.'; +const IDENTITY_REMEDIATION = + 'Deployment identity is required. Pass `--source ` or set a named identity in stellarforge.config.json.'; + +class FakeChild implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + #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 === 'exit') { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } + return this; + } + + kill(): boolean { + return true; + } + + exitWith(code: number): void { + this.#exitListener?.(code, null); + } +} + +function writeCargoWorkspace(root: string): void { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); +} + +function writeConfig(root: string, value: unknown): void { + writeFileSync(join(root, PROJECT_CONFIG_FILENAME), JSON.stringify(value)); +} + +async function takeRejection(running: Promise): Promise { + return running.then( + () => undefined, + (reason: unknown) => reason, + ); +} + +describe('deploy missing-configuration remediation', () => { + it('explains how to supply a missing network without starting work', async () => { + const captured = createCapturedTerminalOutput(); + let planCalled = false; + let spawnCalled = false; + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolveConfig: () => ({}), + resolvePlan: () => { + planCalled = true; + return []; + }, + spawnProcess: () => { + spawnCalled = true; + throw new Error('The deploy subprocess must not start.'); + }, + }); + + const error = await takeRejection(command.parseAsync(['node', 'deploy'])); + + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as ValidationCliError).userMessage).toBe(NETWORK_REMEDIATION); + expect(planCalled).toBe(false); + expect(spawnCalled).toBe(false); + }); + + it('explains how to supply a missing identity without starting work', async () => { + const captured = createCapturedTerminalOutput(); + let planCalled = false; + let spawnCalled = false; + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolveConfig: () => ({ network: 'testnet' }), + resolvePlan: () => { + planCalled = true; + return []; + }, + spawnProcess: () => { + spawnCalled = true; + throw new Error('The deploy subprocess must not start.'); + }, + }); + + const error = await takeRejection(command.parseAsync(['node', 'deploy'])); + + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as ValidationCliError).userMessage).toBe( + IDENTITY_REMEDIATION, + ); + expect(planCalled).toBe(false); + expect(spawnCalled).toBe(false); + }); + + it('uses distinct messages for the missing-network and missing-identity cases', () => { + expect(NETWORK_REMEDIATION).not.toBe(IDENTITY_REMEDIATION); + }); + + it('reports the missing network for a version-only project configuration', () => + withTempDirectory(async (root) => { + writeConfig(root, { version: 1 }); + 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'])).rejects.toThrow( + NETWORK_REMEDIATION, + ); + expect(spawnCalled).toBe(false); + })); + + it('reports the missing identity when only the network is configured', () => + withTempDirectory(async (root) => { + writeConfig(root, { version: 1, network: 'testnet' }); + 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'])).rejects.toThrow( + IDENTITY_REMEDIATION, + ); + expect(spawnCalled).toBe(false); + })); + + it('never falls back to an implicit network default', () => + withTempDirectory(async (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'])).rejects.toThrow( + NETWORK_REMEDIATION, + ); + expect(spawnCalled).toBe(false); + })); + + it('proceeds once the missing values are supplied on the CLI', () => + withTempDirectory(async (root) => { + writeCargoWorkspace(root); + const child = new FakeChild(); + const captured = createCapturedTerminalOutput(); + const command = createDeployCommand({ + cwd: () => root, + output: captured.output, + spawnProcess: () => child, + }); + + const running = command.parseAsync([ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'deployer', + ]); + child.exitWith(0); + await running; + + expect(captured.stdoutText()).toContain( + 'Deploying smart contract to Stellar Testnet.', + ); + })); +}); diff --git a/tests/deploy-network-rejection.test.ts b/tests/deploy-network-rejection.test.ts new file mode 100644 index 0000000..ac311f5 --- /dev/null +++ b/tests/deploy-network-rejection.test.ts @@ -0,0 +1,106 @@ +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 REJECTION_MESSAGE = + 'StellarForge deploy supports only the explicit `--network testnet` target in the MVP.'; + +const rejectedNetworks = [ + 'mainnet', + 'futurenet', + 'main', + 'public', + 'local', + 'TESTNET', + 'Testnet', + ' testnet', + 'testnet ', + 'test net', + 'testnet\n', + 'testnet\r\n', + '', +]; + +function writeCargoWorkspace(root: string): void { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); +} + +function expectNetworkRejection(network: string, root: string): void { + let thrown: unknown; + try { + resolveTestnetDeploymentPlan({ cwd: root, network, source: 'alice' }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ValidationCliError); + expect((thrown as ValidationCliError).userMessage).toBe(REJECTION_MESSAGE); +} + +describe('Stellar deploy network rejection matrix', () => { + it.each(rejectedNetworks)('rejects non-Testnet target %j', (network) => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expectNetworkRejection(network, root); + })); + + it('accepts only the explicit Testnet target', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect( + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'alice', + }), + ).toHaveLength(1); + })); + + it('rejects a non-Testnet CLI network before any process is started', () => + 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', + 'mainnet', + '--source', + 'alice', + ]), + ).rejects.toBeInstanceOf(ValidationCliError); + expect(spawnCalled).toBe(false); + })); + + it('does not echo a rejected network value', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + try { + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'SENTINEL-NETWORK', + source: 'alice', + }); + throw new Error('Expected network validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).not.toContain('SENTINEL-NETWORK'); + } + })); +});