diff --git a/src/common/validators/stellar-address.validator.spec.ts b/src/common/validators/stellar-address.validator.spec.ts new file mode 100644 index 00000000..23e6cc88 --- /dev/null +++ b/src/common/validators/stellar-address.validator.spec.ts @@ -0,0 +1,106 @@ +import { validate } from 'class-validator'; +import { + isValidStellarAddress, + IsStellarAddress, +} from './stellar-address.validator'; + +// The jest config maps @stellar/stellar-sdk to a lightweight mock that has no +// StrKey, so this spec substitutes a real StrKey-equivalent implementation +// (base32 + version byte + CRC16-XModem) instead of depending on the mock. +jest.mock('@stellar/stellar-sdk', () => { + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const crc16 = (bytes: number[]): number => { + let crc = 0; + for (const b of bytes) { + crc ^= b << 8; + for (let i = 0; i < 8; i++) { + crc = + crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; + }; + const decode = (s: string): number[] | null => { + let bits = 0; + let acc = 0; + const out: number[] = []; + for (const ch of s) { + const v = ALPHABET.indexOf(ch); + if (v < 0) return null; + acc = (acc << 5) | v; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.push((acc >> bits) & 0xff); + } + } + return out; + }; + return { + StrKey: { + isValidEd25519PublicKey: (s: string): boolean => { + if (s.length !== 56 || s[0] !== 'G') return false; + const bytes = decode(s); + if (!bytes || bytes.length !== 35) return false; + const payload = bytes.slice(0, 33); + const crc = crc16(payload); + return bytes[33] === (crc & 0xff) && bytes[34] === crc >> 8; + }, + }, + }; +}); + +const VALID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; + +class Dto { + @IsStellarAddress() + address!: unknown; +} + +describe('isValidStellarAddress', () => { + it('accepts a valid G… public key', () => { + expect(isValidStellarAddress(VALID)).toBe(true); + }); + + it('rejects a key with a bad checksum', () => { + const bad = VALID.slice(0, -1) + (VALID.endsWith('A') ? 'B' : 'A'); + expect(isValidStellarAddress(bad)).toBe(false); + }); + + it('rejects M… (muxed) and C… (contract) addresses', () => { + expect(isValidStellarAddress('M' + VALID.slice(1))).toBe(false); + expect(isValidStellarAddress('C' + VALID.slice(1))).toBe(false); + }); + + it('rejects a lowercase key', () => { + expect(isValidStellarAddress(VALID.toLowerCase())).toBe(false); + }); + + it('rejects wrong lengths and empty strings', () => { + expect(isValidStellarAddress(VALID.slice(0, -1))).toBe(false); + expect(isValidStellarAddress('')).toBe(false); + }); + + it.each([undefined, null, 123, {}, [], true])( + 'rejects non-string %p', + (value) => { + expect(isValidStellarAddress(value)).toBe(false); + }, + ); +}); + +describe('@IsStellarAddress', () => { + it('passes a valid address', async () => { + const dto = Object.assign(new Dto(), { address: VALID }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('fails an invalid address with the property name in the message', async () => { + const dto = Object.assign(new Dto(), { address: 'nope' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints?.isStellarAddress).toContain( + 'address must be a valid Stellar public key', + ); + }); +}); diff --git a/src/config/configuration.spec.ts b/src/config/configuration.spec.ts new file mode 100644 index 00000000..4626bcac --- /dev/null +++ b/src/config/configuration.spec.ts @@ -0,0 +1,97 @@ +import configuration from './configuration'; + +const ENV_KEYS = [ + 'NODE_ENV', + 'PORT', + 'DATABASE_URL', + 'DATABASE_SYNCHRONIZE', + 'DATABASE_LOGGING', + 'JWT_SECRET', + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'GITHUB_WEBHOOK_SECRET', + 'ANALYTICS_PLATFORM_SUMMARY_TTL_MS', + 'ESCROW_CONTRACT_ID', + 'MAINTENANCE_POOL_CONTRACT_ID', + 'ESCROW_DEADLINE_SECONDS', + 'USDC_TOKEN_CONTRACT_ID', + 'XLM_TOKEN_CONTRACT_ID', +]; + +describe('configuration', () => { + const original = { ...process.env }; + + beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + afterAll(() => { + process.env = original; + }); + + it('falls back to defaults when nothing is set', () => { + const config = configuration(); + expect(config.env).toBe('development'); + expect(config.port).toBe(3000); + expect(config.database).toEqual({ + url: 'postgresql://postgres:postgres@localhost:5432/mergefi', + synchronize: false, + logging: false, + }); + expect(config.jwt.secret).toBe('insecure-dev-secret'); + expect(config.github.webhookSecret).toBe(''); + expect(config.analytics.platformSummaryTtlMs).toBe(60000); + expect(config.stellar.escrowDeadlineSeconds).toBe(7776000); + expect(config.stellar.assetContractIds).toEqual({ USDC: '', XLM: '' }); + }); + + it('parses integers and booleans from the environment', () => { + process.env.PORT = '8080'; + process.env.ESCROW_DEADLINE_SECONDS = '120'; + process.env.DATABASE_SYNCHRONIZE = 'true'; + process.env.DATABASE_LOGGING = 'false'; + const config = configuration(); + expect(config.port).toBe(8080); + expect(config.stellar.escrowDeadlineSeconds).toBe(120); + expect(config.database.synchronize).toBe(true); + expect(config.database.logging).toBe(false); + }); + + it('only treats the exact string "true" as a true boolean', () => { + process.env.DATABASE_SYNCHRONIZE = '1'; + expect(configuration().database.synchronize).toBe(false); + }); + + it('keeps an empty-string env var where ?? is used, but falls back where || is used', () => { + process.env.JWT_SECRET = ''; + process.env.GITHUB_CLIENT_ID = ''; + const config = configuration(); + expect(config.jwt.secret).toBe(''); + expect(config.github.clientId).toBe('mock_client_id_12345'); + }); + + it('yields NaN for a non-numeric PORT or ESCROW_DEADLINE_SECONDS', () => { + process.env.PORT = 'abc'; + process.env.ESCROW_DEADLINE_SECONDS = 'soon'; + const config = configuration(); + expect(config.port).toBeNaN(); + expect(config.stellar.escrowDeadlineSeconds).toBeNaN(); + }); + + describe('maintenancePoolContractId', () => { + it('falls back to ESCROW_CONTRACT_ID when unset', () => { + process.env.ESCROW_CONTRACT_ID = 'CESCROW'; + expect(configuration().stellar.maintenancePoolContractId).toBe('CESCROW'); + }); + + it('prefers MAINTENANCE_POOL_CONTRACT_ID when set', () => { + process.env.ESCROW_CONTRACT_ID = 'CESCROW'; + process.env.MAINTENANCE_POOL_CONTRACT_ID = 'CPOOL'; + expect(configuration().stellar.maintenancePoolContractId).toBe('CPOOL'); + }); + + it('is empty when neither is set', () => { + expect(configuration().stellar.maintenancePoolContractId).toBe(''); + }); + }); +}); diff --git a/src/config/validate-required-config.spec.ts b/src/config/validate-required-config.spec.ts new file mode 100644 index 00000000..28e51fc9 --- /dev/null +++ b/src/config/validate-required-config.spec.ts @@ -0,0 +1,108 @@ +import configuration from './configuration'; +import { + assertRequiredConfig, + collectConfigIssues, + DEFAULT_LOCAL_DATABASE_URL, + INSECURE_DEFAULT_JWT_SECRET, +} from './validate-required-config'; + +type Config = Parameters[0]; + +const validProduction = (): Config => ({ + ...configuration(), + env: 'production', + database: { + url: 'postgresql://user:pw@db.internal:5432/mergefi', + synchronize: false, + logging: false, + }, + jwt: { secret: 'a-long-random-secret', expiresIn: '7d' }, + github: { + clientId: 'client-id', + clientSecret: 'client-secret', + oauthCallbackUrl: 'https://api.example.com/api/auth/github/callback', + apiToken: 'token', + webhookSecret: 'webhook-secret', + syncAllowedRepos: '', + }, + stellar: { + ...configuration().stellar, + escrowContractId: 'CESCROW', + treasurySecret: 'STREASURY', + }, +}); + +const keys = (config: Config) => collectConfigIssues(config).map((i) => i.key); + +describe('collectConfigIssues', () => { + it('is a no-op outside production, even with every secret missing', () => { + const config: Config = { + ...configuration(), + env: 'development', + jwt: { secret: INSECURE_DEFAULT_JWT_SECRET, expiresIn: '7d' }, + }; + expect(collectConfigIssues(config)).toEqual([]); + expect(() => assertRequiredConfig(config)).not.toThrow(); + }); + + it('reports nothing for a fully configured production environment', () => { + expect(collectConfigIssues(validProduction())).toEqual([]); + }); + + it.each([ + ['GITHUB_WEBHOOK_SECRET', (c: Config) => (c.github.webhookSecret = '')], + ['GITHUB_CLIENT_ID', (c: Config) => (c.github.clientId = '')], + ['GITHUB_CLIENT_SECRET', (c: Config) => (c.github.clientSecret = ' ')], + ['ESCROW_CONTRACT_ID', (c: Config) => (c.stellar.escrowContractId = '')], + ['TREASURY_SECRET', (c: Config) => (c.stellar.treasurySecret = '')], + ['JWT_SECRET', (c: Config) => (c.jwt.secret = '')], + ])('flags a missing %s', (key, mutate) => { + const config = validProduction(); + mutate(config); + expect(keys(config)).toEqual([key]); + }); + + it('flags the insecure default JWT secret', () => { + const config = validProduction(); + config.jwt.secret = INSECURE_DEFAULT_JWT_SECRET; + const issues = collectConfigIssues(config); + expect(issues).toHaveLength(1); + expect(issues[0].key).toBe('JWT_SECRET'); + expect(issues[0].message).toContain('insecure dev default'); + }); + + it('flags the default and an empty DATABASE_URL', () => { + const config = validProduction(); + config.database.url = DEFAULT_LOCAL_DATABASE_URL; + expect(keys(config)).toEqual(['DATABASE_URL']); + config.database.url = ''; + expect(keys(config)).toEqual(['DATABASE_URL']); + }); + + it('collects every problem at once', () => { + const config = validProduction(); + config.jwt.secret = INSECURE_DEFAULT_JWT_SECRET; + config.database.url = DEFAULT_LOCAL_DATABASE_URL; + config.github.webhookSecret = ''; + expect(keys(config)).toEqual([ + 'JWT_SECRET', + 'DATABASE_URL', + 'GITHUB_WEBHOOK_SECRET', + ]); + }); +}); + +describe('assertRequiredConfig', () => { + it('throws listing every issue in production', () => { + const config = validProduction(); + config.jwt.secret = INSECURE_DEFAULT_JWT_SECRET; + config.stellar.treasurySecret = ''; + expect(() => assertRequiredConfig(config)).toThrow( + /Refusing to start in production[\s\S]*JWT_SECRET[\s\S]*TREASURY_SECRET/, + ); + }); + + it('does not throw for a valid production config', () => { + expect(() => assertRequiredConfig(validProduction())).not.toThrow(); + }); +});