From bb397145e8a43650f217b58240e3a766173bd220 Mon Sep 17 00:00:00 2001 From: Akargi <256307631+akargi@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:35:58 +0100 Subject: [PATCH] test(config): add malformed-JSON and filesystem-boundary regression suites Closes #176 Closes #177 --- tests/config-filesystem-boundaries.test.ts | 114 +++++++++++++++++++++ tests/config-malformed-json.test.ts | 107 +++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 tests/config-filesystem-boundaries.test.ts create mode 100644 tests/config-malformed-json.test.ts diff --git a/tests/config-filesystem-boundaries.test.ts b/tests/config-filesystem-boundaries.test.ts new file mode 100644 index 0000000..39cfb97 --- /dev/null +++ b/tests/config-filesystem-boundaries.test.ts @@ -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(); + }), + ); +}); diff --git a/tests/config-malformed-json.test.ts b/tests/config-malformed-json.test.ts new file mode 100644 index 0000000..d211df5 --- /dev/null +++ b/tests/config-malformed-json.test.ts @@ -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', + '', + ' ', + '', + '{"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', + }); + })); +});