/*
* 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');
});
});