/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader'; import {type ConfigObject, deepMerge} from '@fluxer/config/src/config_loader/ConfigObjectMerge'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; function createTempConfig(config: Record): string { const dir = mkdtempSync(path.join(tmpdir(), 'fluxer-config-test-')); const configPath = path.join(dir, 'config.json'); writeFileSync(configPath, JSON.stringify(config)); return configPath; } function makeMinimalConfig(overrides: Record = {}): Record { const base: ConfigObject = { env: 'test', domain: { base_domain: 'localhost', public_port: 8080, }, database: { backend: 'sqlite', sqlite_path: ':memory:', }, s3: { access_key_id: 'test-key', secret_access_key: 'test-secret', endpoint: 'http://localhost:9000', }, services: { server: {port: 8772, host: '0.0.0.0'}, media_proxy: {secret_key: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'}, admin: { secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', oauth_client_secret: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', }, app_proxy: {port: 8773, sentry_report_host: 'sentry.io', sentry_dsn: 'https://test@sentry.io/1'}, marketing: { enabled: false, port: 8774, host: '0.0.0.0', secret_key_base: 'marketing0123456789abcdef0123456789abcdef0123456789abcdef01234567', }, gateway: { port: 8771, api_host: 'http://localhost:8772/api', admin_reload_secret: 'deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567', media_proxy_endpoint: 'http://localhost:8772/media', }, }, gateway: { rpc_secret: 'rpc-test-secret', }, auth: { sudo_mode_secret: 'sudo-test-secret', connection_initiation_secret: 'connection-initiation-test-secret', vapid: { public_key: 'test-vapid-public-key', private_key: 'test-vapid-private-key', }, }, integrations: { search: { url: 'http://127.0.0.1:7700', api_key: 'test-search-key', }, }, }; return deepMerge(base, overrides as ConfigObject); } describe('ConfigLoader', () => { let tempPaths: Array = []; beforeEach(() => { resetConfig(); }); afterEach(() => { resetConfig(); for (const p of tempPaths) { try { rmSync(path.dirname(p), {recursive: true, force: true}); } catch {} } tempPaths = []; }); test('loadConfig loads and caches config', async () => { const configPath = createTempConfig(makeMinimalConfig()); tempPaths.push(configPath); const config = await loadConfig([configPath]); expect(config.env).toBe('test'); expect(config.domain.base_domain).toBe('localhost'); }); test('getConfig throws when config is not loaded', () => { expect(() => getConfig()).toThrow('Config not loaded'); }); test('resetConfig clears the cache', async () => { const configPath = createTempConfig(makeMinimalConfig()); tempPaths.push(configPath); await loadConfig([configPath]); expect(() => getConfig()).not.toThrow(); resetConfig(); expect(() => getConfig()).toThrow('Config not loaded'); }); test('throws when no config file is found', async () => { await expect(loadConfig(['/nonexistent/path.json'])).rejects.toThrow('No config file found'); }); test('throws when config paths array is empty', async () => { await expect(loadConfig([])).rejects.toThrow('FLUXER_CONFIG must be set'); }); test('derives endpoints from domain config', async () => { const configPath = createTempConfig(makeMinimalConfig()); tempPaths.push(configPath); const config = await loadConfig([configPath]); expect(config.endpoints.api).toContain('localhost'); expect(config.endpoints.api).toContain('/api'); expect(config.endpoints.gateway).toContain('ws'); }); test('endpoint_overrides take precedence over derived endpoints', async () => { const configPath = createTempConfig( makeMinimalConfig({ endpoint_overrides: { api: 'https://custom-api.example.com', api_client: 'https://custom-api-client.example.com', gateway: 'wss://custom-gw.example.com', }, }), ); tempPaths.push(configPath); const config = await loadConfig([configPath]); expect(config.endpoints.api).toBe('https://custom-api.example.com'); expect(config.endpoints.api_client).toBe('https://custom-api-client.example.com'); expect(config.endpoints.gateway).toBe('wss://custom-gw.example.com'); expect(config.endpoints.app).toContain('localhost'); }); test('allows unknown properties (but warns)', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const configPath = createTempConfig( makeMinimalConfig({ extra_root: true, auth: { sudo_mode_secret: 'sudo-test-secret', connection_initiation_secret: 'connection-initiation-test-secret', vapid: { public_key: 'test-vapid-public-key', private_key: 'test-vapid-private-key', }, extra_auth: 'oops', }, }), ); tempPaths.push(configPath); await expect(loadConfig([configPath])).resolves.toBeDefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_root"')); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_auth"')); warnSpy.mockRestore(); }); test('allows voice enabled without global credentials when default_region is omitted', async () => { const configPath = createTempConfig( makeMinimalConfig({ integrations: { voice: { enabled: true, }, }, }), ); tempPaths.push(configPath); const config = await loadConfig([configPath]); expect(config.integrations.voice.enabled).toBe(true); expect(config.integrations.voice.api_key).toBeUndefined(); expect(config.integrations.voice.api_secret).toBeUndefined(); }); test('requires voice credentials when default_region bootstrap is configured', async () => { const configPath = createTempConfig( makeMinimalConfig({ integrations: { voice: { enabled: true, default_region: { id: 'default', name: 'Default', emoji: ':earth_africa:', latitude: 0, longitude: 0, }, }, }, }), ); tempPaths.push(configPath); await expect(loadConfig([configPath])).rejects.toThrow('api_key'); await expect(loadConfig([configPath])).rejects.toThrow('api_secret'); }); });