Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/deploy-argument-order.test.ts
Original file line number Diff line number Diff line change
@@ -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`');
}));
});
110 changes: 110 additions & 0 deletions tests/deploy-contract-id-output.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
}));
});
44 changes: 44 additions & 0 deletions tests/deploy-shell-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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 }),
);
});
});
136 changes: 136 additions & 0 deletions tests/deploy-source-rejection.test.ts
Original file line number Diff line number Diff line change
@@ -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<in',
'-flag',
'--source',
'name=value',
'two words',
'tab\tseparated',
'line\nbreak',
'quote"name',
'',
' ',
' leading',
'trailing ',
];

const sensitiveSources = [
`${SENTINEL} words`,
`../${SENTINEL}`,
`${SENTINEL}/child`,
`-${SENTINEL}`,
`${SENTINEL}; id`,
`${SENTINEL}$(id)`,
];

function writeCargoWorkspace(root: string): void {
writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n');
}

describe('Stellar deploy source validation', () => {
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);
}));
});
Loading