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
121 changes: 121 additions & 0 deletions tests/config-identity-alias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 { validateIdentityAlias } from '../src/config/schema.js';
import { ValidationCliError } from '../src/errors/index.js';
import { withTempDirectory } from './helpers/index.js';

const SENTINEL = 'SENTINEL7';

const validAliases = [
'deployer',
'a',
'A',
'A1',
'my-identity',
'my_identity',
'Team-Deployer_2',
'a'.repeat(63),
'a'.repeat(64),
];

const invalidAliases = [
'',
' ',
'1start',
'-leading',
'_leading',
'has space',
' leading',
'trailing ',
'two words',
'a'.repeat(65),
'alias!',
'alias.',
'a.b',
'../identity',
'alice/bob',
'/absolute',
'C:\\Users\\deployer',
'~/deployer',
'alias;id',
'alias$(id)',
'alias|id',
'alias\tid',
'alias\nid',
'caf\u00e9',
'S'.concat('A'.repeat(55)),
'G'.concat('A'.repeat(55)),
];

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

describe('configuration identity alias boundaries', () => {
it.each(validAliases)('accepts identity alias %j', (alias) => {
expect(validateIdentityAlias(alias)).toBe(alias);
});

it.each(invalidAliases)('rejects identity alias %j', (alias) => {
expect(() => validateIdentityAlias(alias)).toThrow(ValidationCliError);
});

it.each(sensitiveAliases)(
'does not echo rejected identity alias %j',
(alias) => {
try {
validateIdentityAlias(alias);
throw new Error('Expected identity validation to fail.');
} catch (error) {
expect(error).toBeInstanceOf(ValidationCliError);
expect((error as Error).message).not.toContain(SENTINEL);
}
},
);

it('loads a supported identity alias from project configuration', () =>
withTempDirectory((root) => {
writeFileSync(
join(root, PROJECT_CONFIG_FILENAME),
JSON.stringify({ version: 1, identity: 'team-deployer' }),
);

expect(loadProjectConfig(root)).toEqual({
version: 1,
identity: 'team-deployer',
});
}));

it('rejects a raw StrKey identity in project configuration', () =>
withTempDirectory((root) => {
writeFileSync(
join(root, PROJECT_CONFIG_FILENAME),
JSON.stringify({ version: 1, identity: 'G'.concat('A'.repeat(55)) }),
);

expect(() => loadProjectConfig(root)).toThrow(ValidationCliError);
}));

it('rejects a non-string identity field', () =>
withTempDirectory((root) => {
writeFileSync(
join(root, PROJECT_CONFIG_FILENAME),
JSON.stringify({ version: 1, identity: 42 }),
);

expect(() => loadProjectConfig(root)).toThrow(
'Configuration identity must be a named Stellar CLI identity alias.',
);
}));
});
124 changes: 124 additions & 0 deletions tests/config-network-enum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 { validateStellarForgeConfig } from '../src/config/schema.js';
import { resolveTestnetDeploymentPlan } from '../src/deployment/project.js';
import { ValidationCliError } from '../src/errors/index.js';
import { withTempDirectory } from './helpers/index.js';

const ENUM_MESSAGE =
'Configuration network must be one of: testnet, futurenet, mainnet.';

const unsupportedNetworks = [
'local',
'public',
'TESTNET',
'Testnet',
'main',
'dev',
'test',
'future',
' testnet',
'testnet ',
'test net',
'',
];

const nonStringNetworks = [1, 0, true, false, null, [], {}];

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

describe('configuration network enum', () => {
it.each(['testnet', 'futurenet', 'mainnet'])(
'accepts supported network %j',
(network) => {
expect(validateStellarForgeConfig({ version: 1, network })).toEqual({
version: 1,
network,
});
},
);

it.each(unsupportedNetworks)(
'rejects unsupported network %j with an actionable message',
(network) => {
expect(() =>
validateStellarForgeConfig({ version: 1, network }),
).toThrow(ENUM_MESSAGE);
},
);

it.each(nonStringNetworks)('rejects non-string network %j', (network) => {
expect(() => validateStellarForgeConfig({ version: 1, network })).toThrow(
ENUM_MESSAGE,
);
});

it('does not echo the rejected network value', () => {
try {
validateStellarForgeConfig({ version: 1, network: 'SENTINEL-NET' });
throw new Error('Expected network validation to fail.');
} catch (error) {
expect(error).toBeInstanceOf(ValidationCliError);
expect((error as Error).message).not.toContain('SENTINEL-NET');
}
});

it('loads a configuration file with a supported network', () =>
withTempDirectory((root) => {
writeFileSync(
join(root, PROJECT_CONFIG_FILENAME),
JSON.stringify({ version: 1, network: 'futurenet' }),
);

expect(loadProjectConfig(root)).toEqual({
version: 1,
network: 'futurenet',
});
}));

it('rejects a configuration file with an unsupported network', () =>
withTempDirectory((root) => {
writeFileSync(
join(root, PROJECT_CONFIG_FILENAME),
JSON.stringify({ version: 1, network: 'local' }),
);

expect(() => loadProjectConfig(root)).toThrow(ENUM_MESSAGE);
}));

it.each(['mainnet', 'futurenet', 'main', 'local', 'Testnet', ''])(
'keeps the deploy command Testnet-only for network %j',
(network) =>
withTempDirectory((root) => {
writeCargoWorkspace(root);

expect(() =>
resolveTestnetDeploymentPlan({
cwd: root,
network,
source: 'alice',
}),
).toThrow('only the explicit `--network testnet`');
}),
);

it('deploys a Cargo workspace on the explicit Testnet target', () =>
withTempDirectory((root) => {
writeCargoWorkspace(root);

expect(
resolveTestnetDeploymentPlan({
cwd: root,
network: 'testnet',
source: 'alice',
}),
).toHaveLength(1);
}));
});
125 changes: 125 additions & 0 deletions tests/config-precedence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
loadProjectConfig,
PROJECT_CONFIG_FILENAME,
resolveProjectConfig,
} from '../src/config/project.js';
import { ValidationCliError } from '../src/errors/index.js';
import { withTempDirectory } from './helpers/index.js';

function writeConfig(root: string, value: unknown): void {
writeFileSync(join(root, PROJECT_CONFIG_FILENAME), JSON.stringify(value));
}

describe('configuration precedence', () => {
it('prefers explicit CLI overrides over project configuration', () =>
withTempDirectory((root) => {
writeConfig(root, {
version: 1,
network: 'futurenet',
identity: 'project-deployer',
});

expect(
resolveProjectConfig(root, {
network: 'testnet',
identity: 'cli-deployer',
}),
).toEqual({ network: 'testnet', identity: 'cli-deployer' });
}));

it('uses project configuration when no CLI override is provided', () =>
withTempDirectory((root) => {
writeConfig(root, {
version: 1,
network: 'futurenet',
identity: 'project-deployer',
});

expect(resolveProjectConfig(root)).toEqual({
network: 'futurenet',
identity: 'project-deployer',
});
}));

it('applies a partial override without discarding the other project value', () =>
withTempDirectory((root) => {
writeConfig(root, {
version: 1,
network: 'futurenet',
identity: 'project-deployer',
});

expect(resolveProjectConfig(root, { identity: 'cli-deployer' })).toEqual({
network: 'futurenet',
identity: 'cli-deployer',
});
expect(resolveProjectConfig(root, { network: 'testnet' })).toEqual({
network: 'testnet',
identity: 'project-deployer',
});
}));

it('does not invent missing values', () =>
withTempDirectory((root) => {
writeConfig(root, { version: 1 });

expect(resolveProjectConfig(root)).toEqual({});
expect(resolveProjectConfig(root, {})).toEqual({});
}));

it('reports a missing configuration without a default', () =>
withTempDirectory((root) => {
expect(loadProjectConfig(root)).toBeUndefined();
expect(resolveProjectConfig(root)).toEqual({});
}));

it('validates a CLI identity override before accepting it', () =>
withTempDirectory((root) => {
expect(() =>
resolveProjectConfig(root, { identity: '../escape-attempt' }),
).toThrow(ValidationCliError);
}));

it('does not consult environment variables for precedence', () =>
withTempDirectory((root) => {
const environmentKeys = [
'STELLARFORGE_NETWORK',
'STELLARFORGE_IDENTITY',
'STELLAR_NETWORK',
'NETWORK',
'IDENTITY',
];
const previous = new Map<string, string | undefined>();
for (const key of environmentKeys) {
previous.set(key, process.env[key]);
process.env[key] = 'mainnet';
}

try {
expect(resolveProjectConfig(root)).toEqual({});

writeConfig(root, {
version: 1,
network: 'testnet',
identity: 'project-deployer',
});

expect(resolveProjectConfig(root)).toEqual({
network: 'testnet',
identity: 'project-deployer',
});
} finally {
for (const key of environmentKeys) {
const value = previous.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}));
});
Loading