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
114 changes: 114 additions & 0 deletions tests/config-filesystem-boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { mkdirSync, symlinkSync, 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 { ValidationCliError } from '../src/errors/index.js';
import {
createTempDirectoryFixture,
withTempDirectory,
} from './helpers/index.js';

function canCreateSymlink(): boolean {
const fixture = createTempDirectoryFixture();
try {
const target = join(fixture.path, 'target');
writeFileSync(target, 'link-target');
symlinkSync(target, join(fixture.path, 'link'));
return true;
} catch {
return false;
} finally {
fixture.cleanup();
}
}

const symlinksSupported = canCreateSymlink();

function writeValidConfig(root: string, name = PROJECT_CONFIG_FILENAME): void {
writeFileSync(
join(root, name),
JSON.stringify({ version: 1, network: 'testnet' }),
);
}

describe('configuration filesystem boundaries', () => {
it('loads only the exact project-root configuration filename', () =>
withTempDirectory((root) => {
writeValidConfig(root);

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

it.each([
'config.json',
'.stellarforge.config.json',
'stellarforge.config.json.bak',
'stellarforge.config.jsonc',
])('ignores a differently named file %j', (name) =>
withTempDirectory((root) => {
writeValidConfig(root, name);

expect(loadProjectConfig(root)).toBeUndefined();
}),
);

it('ignores a configuration file outside the project root', () =>
withTempDirectory((root) => {
const nested = join(root, 'nested');
mkdirSync(nested);
writeValidConfig(nested);

expect(loadProjectConfig(root)).toBeUndefined();
}));

it('rejects a directory in place of the configuration file', () =>
withTempDirectory((root) => {
mkdirSync(join(root, PROJECT_CONFIG_FILENAME));

expect(() => loadProjectConfig(root)).toThrow(ValidationCliError);
expect(() => loadProjectConfig(root)).toThrow(
'must be a regular project file',
);
}));

it.skipIf(!symlinksSupported)('rejects a symlinked configuration file', () =>
withTempDirectory((root) => {
const target = join(root, 'actual-config.json');
writeFileSync(target, JSON.stringify({ version: 1, network: 'testnet' }));
symlinkSync(target, join(root, PROJECT_CONFIG_FILENAME));

expect(() => loadProjectConfig(root)).toThrow('not a symbolic link');
}),
);

it.skipIf(!symlinksSupported)(
'rejects a directory symlink in place of the configuration file',
() =>
withTempDirectory((root) => {
const target = join(root, 'config-dir');
mkdirSync(target);
symlinkSync(target, join(root, PROJECT_CONFIG_FILENAME), 'junction');

expect(() => loadProjectConfig(root)).toThrow('not a symbolic link');
}),
);

it.skipIf(!symlinksSupported)(
'treats a dangling symlink as a missing configuration',
() =>
withTempDirectory((root) => {
symlinkSync(
join(root, 'missing-target.json'),
join(root, PROJECT_CONFIG_FILENAME),
);

expect(loadProjectConfig(root)).toBeUndefined();
}),
);
});
107 changes: 107 additions & 0 deletions tests/config-malformed-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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 { ValidationCliError } from '../src/errors/index.js';
import { withTempDirectory } from './helpers/index.js';

const INVALID_JSON_MESSAGE = 'stellarforge.config.json is not valid JSON.';

const malformedJson = [
'{',
'{"version": 1',
'{"version": 1,}',
'{"version": 1 "network": "testnet"}',
'{"version": 1,,}',
'not json at all',
'',
' ',
'<xml/>',
'{"version": 1}\n{',
'[1, 2, 3',
];

const sentinelContents = [
'{"secret": "SENTINEL-JSON"',
'{SENTINEL-JSON',
'[SENTINEL-JSON',
];

describe('configuration malformed-JSON handling', () => {
it.each(malformedJson)(
'rejects malformed JSON content %j with a stable message',
(content) =>
withTempDirectory((root) => {
writeFileSync(join(root, PROJECT_CONFIG_FILENAME), content);

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

it('produces the same position-independent message for every malformed input', () =>
withTempDirectory((root) => {
const messages = malformedJson.map((content) => {
writeFileSync(join(root, PROJECT_CONFIG_FILENAME), content);

try {
loadProjectConfig(root);
return 'NO-ERROR';
} catch (error) {
expect(error).toBeInstanceOf(ValidationCliError);
return (error as Error).message;
}
});

expect(new Set(messages).size).toBe(1);
expect(messages[0]).toBe(INVALID_JSON_MESSAGE);
}));

it('does not echo raw malformed content or parser positions', () =>
withTempDirectory((root) => {
for (const content of sentinelContents) {
writeFileSync(join(root, PROJECT_CONFIG_FILENAME), content);

try {
loadProjectConfig(root);
throw new Error('Expected malformed JSON to fail.');
} catch (error) {
expect(error).toBeInstanceOf(ValidationCliError);

const message = (error as Error).message;
expect(message).not.toContain('SENTINEL-JSON');
expect(message).not.toMatch(/\bposition\b/i);
expect(message).not.toMatch(/\bline\b/i);
expect(message).not.toMatch(/\bcolumn\b/i);
}
}
}));

it.each(['null', '[]', '"text"', '1', 'true'])(
'rejects valid JSON that is not an object: %j',
(content) =>
withTempDirectory((root) => {
writeFileSync(join(root, PROJECT_CONFIG_FILENAME), content);

expect(() => loadProjectConfig(root)).toThrow(
'must contain a JSON object',
);
}),
);

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

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