mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
1122 lines
42 KiB
TypeScript
1122 lines
42 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {generateKeyPairSync} from 'node:crypto';
|
|
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
|
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
|
|
|
const MINIMAL_ENV: Record<string, string> = {
|
|
FLUXER_ENV: 'test',
|
|
FLUXER_BASE_DOMAIN: 'localhost',
|
|
FLUXER_PUBLIC_SCHEME: 'http',
|
|
FLUXER_PUBLIC_PORT: '8088',
|
|
FLUXER_CASSANDRA_HOSTS: '127.0.0.1',
|
|
FLUXER_CASSANDRA_PORT: '9042',
|
|
FLUXER_CASSANDRA_KEYSPACE: 'fluxer_test',
|
|
FLUXER_CASSANDRA_LOCAL_DC: 'datacenter1',
|
|
FLUXER_CASSANDRA_USERNAME: 'test-user',
|
|
FLUXER_CASSANDRA_PASSWORD: 'test-password',
|
|
FLUXER_S3_ENDPOINT: 'http://127.0.0.1:3900',
|
|
FLUXER_S3_ACCESS_KEY_ID: 'test-key',
|
|
FLUXER_S3_SECRET_ACCESS_KEY: 'test-secret',
|
|
FLUXER_MEDIA_PROXY_SECRET_KEY: 'test-media-secret',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=',
|
|
FLUXER_ADMIN_SECRET_KEY_BASE: 'test-admin-secret',
|
|
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: 'test-admin-oauth-secret',
|
|
FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token',
|
|
FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret',
|
|
FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret',
|
|
FLUXER_PROFILE_PSEUDONYM_SECRET: 'test-profile-pseudonym-secret',
|
|
FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw',
|
|
FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o',
|
|
};
|
|
|
|
function generateVapidPair(): {publicKey: string; privateKey: string} {
|
|
const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
|
|
const jwk = privateKey.export({format: 'jwk'});
|
|
const x = Buffer.from(jwk.x ?? '', 'base64url');
|
|
const y = Buffer.from(jwk.y ?? '', 'base64url');
|
|
const d = Buffer.from(jwk.d ?? '', 'base64url');
|
|
return {
|
|
publicKey: Buffer.concat([Buffer.from([0x04]), x, y]).toString('base64url'),
|
|
privateKey: d.toString('base64url'),
|
|
};
|
|
}
|
|
|
|
function stubMinimalEnv(overrides: Record<string, string> = {}): void {
|
|
for (const [key, value] of Object.entries({...MINIMAL_ENV, ...overrides})) {
|
|
vi.stubEnv(key, value);
|
|
}
|
|
}
|
|
|
|
function clearFluxerEnv(): void {
|
|
for (const key of Object.keys(process.env)) {
|
|
if (key.startsWith('FLUXER_')) {
|
|
vi.stubEnv(key, undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('ConfigLoader', () => {
|
|
beforeEach(() => {
|
|
resetConfig();
|
|
clearFluxerEnv();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetConfig();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
test('loadConfig builds and caches config from FLUXER environment variables', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.env).toBe('test');
|
|
expect(config.domain.base_domain).toBe('localhost');
|
|
expect(config.database.backend).toBe('postgres');
|
|
expect(config.database.postgres.database).toBe('fluxer');
|
|
expect(config.database.cassandra.hosts).toEqual(['127.0.0.1']);
|
|
|
|
vi.stubEnv('FLUXER_BASE_DOMAIN', 'changed.example');
|
|
expect((await loadConfig()).domain.base_domain).toBe('localhost');
|
|
});
|
|
|
|
test('loadConfig reads secrets from NAME_FILE', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'fluxer-config-file-'));
|
|
try {
|
|
const path = join(dir, 'postgres_password');
|
|
writeFileSync(path, 'from-secret-file\n');
|
|
stubMinimalEnv({FLUXER_POSTGRES_PASSWORD: '', FLUXER_POSTGRES_PASSWORD_FILE: path});
|
|
const config = await loadConfig();
|
|
expect(config.database.postgres.password).toBe('from-secret-file');
|
|
} finally {
|
|
rmSync(dir, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
test('loadConfig rejects NAME and NAME_FILE together', async () => {
|
|
stubMinimalEnv({FLUXER_SUDO_MODE_SECRET_FILE: '/run/secrets/sudo'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_SUDO_MODE_SECRET and FLUXER_SUDO_MODE_SECRET_FILE are both set');
|
|
});
|
|
|
|
test('getConfig throws when config is not loaded', () => {
|
|
expect(() => getConfig()).toThrow('Config not loaded');
|
|
});
|
|
|
|
test('resetConfig clears the cache', async () => {
|
|
stubMinimalEnv();
|
|
await loadConfig();
|
|
expect(() => getConfig()).not.toThrow();
|
|
resetConfig();
|
|
expect(() => getConfig()).toThrow('Config not loaded');
|
|
});
|
|
|
|
test('derives endpoints from domain config', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.endpoints.api).toBe('http://localhost:8088/api');
|
|
expect(config.endpoints.gateway).toBe('ws://localhost:8088/gateway');
|
|
});
|
|
|
|
test('endpoint overrides take precedence over derived endpoints', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_ENDPOINT: 'https://custom-api.example.com',
|
|
FLUXER_API_CLIENT_ENDPOINT: 'https://custom-api-client.example.com',
|
|
FLUXER_GATEWAY_ENDPOINT: 'wss://custom-gw.example.com',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
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).toBe('http://localhost:8088');
|
|
});
|
|
|
|
test('inserts the public port into portless endpoints on a non-standard port', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_MARKETING_ENDPOINT: 'http://localhost',
|
|
FLUXER_MEDIA_ENDPOINT: 'http://localhost/media',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost/media',
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'http://localhost',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.endpoints.marketing).toBe('http://localhost:8088');
|
|
expect(config.endpoints.media).toBe('http://localhost:8088/media');
|
|
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['http://localhost:8088']);
|
|
});
|
|
|
|
test('leaves a default https install untouched', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
FLUXER_MARKETING_ENDPOINT: 'https://chat.example',
|
|
FLUXER_MEDIA_ENDPOINT: 'https://chat.example/media',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example/media',
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.endpoints.marketing).toBe('https://chat.example');
|
|
expect(config.endpoints.media).toBe('https://chat.example/media');
|
|
expect(config.endpoints.api).toBe('https://chat.example/api');
|
|
expect(config.endpoints.gateway).toBe('wss://chat.example/gateway');
|
|
expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example/media');
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example']);
|
|
});
|
|
|
|
test('leaves a default http install untouched', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example',
|
|
FLUXER_PUBLIC_SCHEME: 'http',
|
|
FLUXER_PUBLIC_PORT: '80',
|
|
FLUXER_MARKETING_ENDPOINT: 'http://chat.example',
|
|
FLUXER_MEDIA_ENDPOINT: 'http://chat.example/media',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://chat.example/media',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.endpoints.marketing).toBe('http://chat.example');
|
|
expect(config.endpoints.media).toBe('http://chat.example/media');
|
|
expect(config.endpoints.gateway).toBe('ws://chat.example/gateway');
|
|
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://chat.example/media');
|
|
});
|
|
|
|
test('leaves foreign hosts and already ported endpoints untouched', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_STATIC_CDN_ENDPOINT: 'https://cdn.example.net',
|
|
FLUXER_MEDIA_ENDPOINT: 'https://media.example.net/media',
|
|
FLUXER_MARKETING_ENDPOINT: 'http://localhost:9999',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost:8088/media',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.endpoints.static_cdn).toBe('https://cdn.example.net');
|
|
expect(config.endpoints.media).toBe('https://media.example.net/media');
|
|
expect(config.endpoints.marketing).toBe('http://localhost:9999');
|
|
expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media');
|
|
});
|
|
|
|
test('normalizes each passkey origin independently', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:
|
|
'http://localhost,http://localhost:3000,https://desktop.example.net,android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
|
|
'http://localhost:8088',
|
|
'http://localhost:3000',
|
|
'https://desktop.example.net',
|
|
'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
|
|
]);
|
|
});
|
|
|
|
test('includes the app origin alongside the default passkey origins', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual([
|
|
'https://fluxer.app',
|
|
'https://web.fluxer.app',
|
|
'https://web.canary.fluxer.app',
|
|
'https://fluxer.com',
|
|
'https://canary.fluxer.com',
|
|
'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w',
|
|
'http://localhost:8088',
|
|
]);
|
|
});
|
|
|
|
test('defaults to no app origin aliases', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.services.api.app_origin_aliases).toEqual([]);
|
|
});
|
|
|
|
test('normalizes and deduplicates app origin aliases', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_APP_ORIGIN_ALIASES:
|
|
'https://Web.Fluxer.App/, https://fluxer.com,https://fluxer.com:443,http://localhost:3000',
|
|
});
|
|
const config = await loadConfig();
|
|
expect(config.services.api.app_origin_aliases).toEqual([
|
|
'https://web.fluxer.app',
|
|
'https://fluxer.com',
|
|
'http://localhost:3000',
|
|
]);
|
|
});
|
|
|
|
test.each(['fluxer.com', 'https://fluxer.com/app', 'ftp://fluxer.com', 'https://[email protected]'])(
|
|
'rejects the app origin alias %s',
|
|
async (alias) => {
|
|
stubMinimalEnv({FLUXER_APP_ORIGIN_ALIASES: alias});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_APP_ORIGIN_ALIASES entry 1 must be an HTTP(S) origin');
|
|
},
|
|
);
|
|
|
|
test('an empty client API endpoint override falls back to the derived endpoint', async () => {
|
|
stubMinimalEnv({FLUXER_API_CLIENT_ENDPOINT: ''});
|
|
expect((await loadConfig()).endpoints.api_client).toBe('http://localhost:8088/api');
|
|
});
|
|
|
|
test('defaults the passkey relying party to the deployment domain', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.passkeys.rp_id).toBe('chat.example.com');
|
|
});
|
|
|
|
test('a blank origin list keeps the built-in origins', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ' ',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.passkeys.additional_allowed_origins).toContain('https://web.fluxer.app');
|
|
expect(config.auth.passkeys.additional_allowed_origins).toContain('https://chat.example.com');
|
|
});
|
|
|
|
test('blank values fall back to the code defaults', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_PORT: '',
|
|
FLUXER_EMAIL_FROM_NAME: '',
|
|
FLUXER_KV_URL: ' ',
|
|
FLUXER_S3_FORCE_PATH_STYLE: '',
|
|
FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS: '',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.services.api.port).toBe(8080);
|
|
expect(config.integrations.email.from_name).toBe('Fluxer');
|
|
expect(config.internal.kv).toBe('redis://localhost:6379/0');
|
|
expect(config.s3?.force_path_style).toBe(false);
|
|
expect(config.services.api.storage_change_feed?.skip_buckets).toBeUndefined();
|
|
});
|
|
|
|
test('reads the email reply-to address', async () => {
|
|
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: '[email protected]'});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.email.reply_to_email).toBe('[email protected]');
|
|
});
|
|
|
|
test('leaves the email reply-to address empty when unset or blank', async () => {
|
|
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: ' '});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.email.reply_to_email).toBe('');
|
|
});
|
|
|
|
test.each(['support', 'Support <[email protected]>', '[email protected],[email protected]', ' [email protected]'])(
|
|
'rejects %j as the email reply-to address',
|
|
async (value) => {
|
|
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: value});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address');
|
|
},
|
|
);
|
|
|
|
test('keeps explicit passkey relying party values', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
FLUXER_PASSKEY_RP_ID: 'example.com',
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://example.com,https://app.example.com',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.passkeys.rp_id).toBe('example.com');
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://example.com', 'https://app.example.com']);
|
|
});
|
|
|
|
test('parses typed named environment variables', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_PORT: '9090',
|
|
FLUXER_CASSANDRA_HOSTS: 'db1,db2',
|
|
FLUXER_POSTGRES_HOST: 'pg1',
|
|
FLUXER_POSTGRES_PORT: '5544',
|
|
FLUXER_POSTGRES_MAX_CONNECTIONS: '7',
|
|
FLUXER_POSTGRES_SSL_CA: '-----BEGIN CERTIFICATE-----\\n-----END CERTIFICATE-----',
|
|
FLUXER_POSTGRES_PREPARED_STATEMENTS: 'false',
|
|
FLUXER_API_WORKER_MODE: 'single_task',
|
|
FLUXER_API_WORKER_TASK: 'processStripeWebhook',
|
|
FLUXER_LIVEKIT_ENABLED: 'true',
|
|
FLUXER_LIVEKIT_DEFAULT_REGION:
|
|
'{"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.services.api.port).toBe(9090);
|
|
expect(config.database.cassandra.hosts).toEqual(['db1', 'db2']);
|
|
expect(config.database.postgres.host).toBe('pg1');
|
|
expect(config.database.postgres.port).toBe(5544);
|
|
expect(config.database.postgres.max_connections).toBe(7);
|
|
expect(config.database.postgres.ssl_ca).toBe('-----BEGIN CERTIFICATE-----\\n-----END CERTIFICATE-----');
|
|
expect(config.database.postgres.prepared_statements).toBe(false);
|
|
expect(config.services.api.worker?.mode).toBe('single_task');
|
|
expect(config.services.api.worker?.task).toBe('processStripeWebhook');
|
|
expect(config.integrations.voice.default_region?.id).toBe('local');
|
|
});
|
|
|
|
test('ignores FLUXER_GATEWAY_PUSH_ENABLED, which only the gateway reads', async () => {
|
|
stubMinimalEnv({FLUXER_GATEWAY_PUSH_ENABLED: 'false'});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.services.gateway).not.toHaveProperty('push_enabled');
|
|
});
|
|
|
|
test('accepts the crosspost worker lane', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_WORKER_MODE: 'single_lane',
|
|
FLUXER_API_WORKER_LANE: 'crosspost',
|
|
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: '{"crosspost":4}',
|
|
});
|
|
const config = await loadConfig();
|
|
expect(config.services.api.worker?.lane).toBe('crosspost');
|
|
expect(config.services.api.worker?.lane_concurrency_overrides?.crosspost).toBe(4);
|
|
});
|
|
|
|
test('rejects an unknown worker lane', async () => {
|
|
stubMinimalEnv({FLUXER_API_WORKER_MODE: 'single_lane', FLUXER_API_WORKER_LANE: 'publishing'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_WORKER_LANE');
|
|
});
|
|
|
|
test('rejects single task worker mode without task env', async () => {
|
|
stubMinimalEnv({FLUXER_API_WORKER_MODE: 'single_task'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_WORKER_TASK');
|
|
});
|
|
|
|
test('leaves the storage change feed disabled by default', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.services.api.storage_change_feed).toEqual({enabled: false, stream: 'STORAGE_CHANGES'});
|
|
});
|
|
|
|
test('maps the storage change feed environment variables', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_STORAGE_CHANGE_FEED_ENABLED: 'true',
|
|
FLUXER_API_STORAGE_CHANGE_FEED_STREAM: 'BACKUP_CHANGES',
|
|
FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS: 'fluxer-uploads, fluxer-harvests',
|
|
});
|
|
const config = await loadConfig();
|
|
expect(config.services.api.storage_change_feed).toEqual({
|
|
enabled: true,
|
|
stream: 'BACKUP_CHANGES',
|
|
skip_buckets: ['fluxer-uploads', 'fluxer-harvests'],
|
|
});
|
|
});
|
|
|
|
test('rejects a storage change feed stream name that JetStream cannot use', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_API_STORAGE_CHANGE_FEED_ENABLED: 'true',
|
|
FLUXER_API_STORAGE_CHANGE_FEED_STREAM: 'storage.changes',
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_STORAGE_CHANGE_FEED_STREAM');
|
|
});
|
|
|
|
test('rejects invalid Postgres typed environment values', async () => {
|
|
stubMinimalEnv({FLUXER_POSTGRES_PORT: 'abc'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PORT');
|
|
});
|
|
|
|
test('rejects a non-integer port', async () => {
|
|
stubMinimalEnv({FLUXER_API_PORT: '80a'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_PORT must be an integer, got "80a"');
|
|
});
|
|
|
|
test('rejects malformed JSON for a JSON-shaped variable', async () => {
|
|
stubMinimalEnv({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON');
|
|
});
|
|
|
|
test('keeps Postgres prepared statements on by default', async () => {
|
|
stubMinimalEnv();
|
|
expect((await loadConfig()).database.postgres.prepared_statements).toBe(true);
|
|
});
|
|
|
|
test('rejects a non-boolean Postgres prepared statements value', async () => {
|
|
stubMinimalEnv({FLUXER_POSTGRES_PREPARED_STATEMENTS: 'maybe'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PREPARED_STATEMENTS');
|
|
});
|
|
|
|
test('keeps the api http timeouts at their defaults', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.services.api.headers_timeout_ms).toBe(30_000);
|
|
expect(config.services.api.request_timeout_ms).toBe(120_000);
|
|
});
|
|
|
|
test('reads the api http timeouts from the environment', async () => {
|
|
stubMinimalEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000', FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
|
const config = await loadConfig();
|
|
expect(config.services.api.headers_timeout_ms).toBe(45_000);
|
|
expect(config.services.api.request_timeout_ms).toBe(600_000);
|
|
});
|
|
|
|
test('rejects a non-numeric api header timeout', async () => {
|
|
stubMinimalEnv({FLUXER_API_HEADERS_TIMEOUT_MS: 'soon'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_HEADERS_TIMEOUT_MS');
|
|
});
|
|
|
|
test('rejects a non-numeric api request timeout', async () => {
|
|
stubMinimalEnv({FLUXER_API_REQUEST_TIMEOUT_MS: 'soon'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_API_REQUEST_TIMEOUT_MS');
|
|
});
|
|
|
|
test('applies the KV mode from the environment', async () => {
|
|
stubMinimalEnv({FLUXER_KV_MODE: 'cluster'});
|
|
expect((await loadConfig()).internal.kv_mode).toBe('cluster');
|
|
});
|
|
|
|
test('rejects an unknown KV mode', async () => {
|
|
stubMinimalEnv({FLUXER_KV_MODE: 'sentinel'});
|
|
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel');
|
|
});
|
|
|
|
test('reads the profile pseudonym secret and requires it in production', async () => {
|
|
stubMinimalEnv();
|
|
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('test-profile-pseudonym-secret');
|
|
resetConfig();
|
|
stubMinimalEnv({FLUXER_PROFILE_PSEUDONYM_SECRET: ''});
|
|
expect((await loadConfig()).auth.profile_pseudonym_secret).toBe('fluxer-dev-profile-pseudonym-secret');
|
|
resetConfig();
|
|
stubMinimalEnv({
|
|
FLUXER_ENV: 'production',
|
|
FLUXER_POSTGRES_HOST: 'postgres.internal',
|
|
FLUXER_POSTGRES_DATABASE: 'fluxer_prod',
|
|
FLUXER_POSTGRES_USERNAME: 'fluxer_app',
|
|
FLUXER_POSTGRES_PASSWORD: 'prod-postgres-secret',
|
|
FLUXER_POSTGRES_SSL: 'true',
|
|
FLUXER_PROFILE_PSEUDONYM_SECRET: '',
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_PROFILE_PSEUDONYM_SECRET is required');
|
|
});
|
|
|
|
test('rejects unsafe production Postgres defaults', async () => {
|
|
stubMinimalEnv({FLUXER_ENV: 'production'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST');
|
|
});
|
|
|
|
test('accepts explicitly configured production Postgres with TLS', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_ENV: 'production',
|
|
FLUXER_POSTGRES_HOST: 'postgres.internal',
|
|
FLUXER_POSTGRES_DATABASE: 'fluxer_prod',
|
|
FLUXER_POSTGRES_USERNAME: 'fluxer_app',
|
|
FLUXER_POSTGRES_PASSWORD: 'prod-postgres-secret',
|
|
FLUXER_POSTGRES_SSL: 'true',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.database.postgres.host).toBe('postgres.internal');
|
|
expect(config.database.postgres.ssl).toBe(true);
|
|
});
|
|
|
|
test('allows self-hosted production Postgres without TLS', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_ENV: 'production',
|
|
FLUXER_SELF_HOSTED: 'true',
|
|
FLUXER_POSTGRES_HOST: 'postgres',
|
|
FLUXER_POSTGRES_DATABASE: 'fluxer',
|
|
FLUXER_POSTGRES_USERNAME: 'fluxer',
|
|
FLUXER_POSTGRES_PASSWORD: 'self-hosted-postgres-secret',
|
|
FLUXER_POSTGRES_SSL: 'false',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.instance.self_hosted).toBe(true);
|
|
expect(config.database.postgres.ssl).toBe(false);
|
|
});
|
|
|
|
test('still requires TLS for non-self-hosted production Postgres', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_ENV: 'production',
|
|
FLUXER_POSTGRES_HOST: 'postgres.internal',
|
|
FLUXER_POSTGRES_DATABASE: 'fluxer_prod',
|
|
FLUXER_POSTGRES_USERNAME: 'fluxer_app',
|
|
FLUXER_POSTGRES_PASSWORD: 'prod-postgres-secret',
|
|
FLUXER_POSTGRES_SSL: 'false',
|
|
});
|
|
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_SSL must be true');
|
|
});
|
|
|
|
test('parses self-host branding, setup, and search engine environment variables', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_SEARCH_ENGINE: 'meilisearch',
|
|
FLUXER_SEARCH_URL: 'http://meilisearch:7700',
|
|
FLUXER_SEARCH_API_KEY: 'meili-key',
|
|
FLUXER_SELF_HOSTED: 'true',
|
|
FLUXER_APP_PRODUCT_NAME: 'Example Chat',
|
|
FLUXER_APP_ICON_URL: 'https://assets.example/icon.png',
|
|
FLUXER_APP_SYMBOL_URL: 'https://assets.example/symbol.png',
|
|
FLUXER_APP_LOGO_URL: 'https://assets.example/logo.png',
|
|
FLUXER_APP_WORDMARK_URL: 'https://assets.example/wordmark.png',
|
|
FLUXER_APP_FAVICON_URL: 'https://assets.example/favicon.png',
|
|
FLUXER_APP_THEME_COLOR: '#123456',
|
|
FLUXER_APP_STATUS_PAGE_URL: 'https://status.example',
|
|
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: 'https://status.example/history',
|
|
FLUXER_INSTANCE_SETUP_CONFIGURED: 'true',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.search.engine).toBe('meilisearch');
|
|
expect(config.integrations.search.url).toBe('http://meilisearch:7700');
|
|
expect(config.integrations.search.api_key).toBe('meili-key');
|
|
expect(config.instance.self_hosted).toBe(true);
|
|
expect(config.instance.branding).toEqual({
|
|
product_name: 'Example Chat',
|
|
icon_url: 'https://assets.example/icon.png',
|
|
symbol_url: 'https://assets.example/symbol.png',
|
|
logo_url: 'https://assets.example/logo.png',
|
|
wordmark_url: 'https://assets.example/wordmark.png',
|
|
favicon_url: 'https://assets.example/favicon.png',
|
|
theme_color: '#123456',
|
|
status_page_url: 'https://status.example',
|
|
status_page_incident_history_url: 'https://status.example/history',
|
|
});
|
|
expect(config.instance.setup.configured).toBe(true);
|
|
});
|
|
|
|
test('leaves the account identity unset by default', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.instance.account_identity).toBeNull();
|
|
});
|
|
|
|
test('treats an empty account identity as unset', async () => {
|
|
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: ' '});
|
|
const config = await loadConfig();
|
|
expect(config.instance.account_identity).toBeNull();
|
|
});
|
|
|
|
test.each([
|
|
['email', 'email'],
|
|
['username', 'username'],
|
|
[' Username ', 'username'],
|
|
])('parses FLUXER_ACCOUNT_IDENTITY=%j', async (raw, expected) => {
|
|
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: raw});
|
|
const config = await loadConfig();
|
|
expect(config.instance.account_identity).toBe(expected);
|
|
});
|
|
|
|
test.each([
|
|
['none', 'none'],
|
|
[' Random ', 'random'],
|
|
['', null],
|
|
])('parses FLUXER_TAG_STYLE=%j', async (raw, expected) => {
|
|
stubMinimalEnv({FLUXER_TAG_STYLE: raw});
|
|
const config = await loadConfig();
|
|
expect(config.instance.tag_style).toBe(expected);
|
|
});
|
|
|
|
test.each(['sequential', 'zero_first'])('rejects the tag style %j', async (raw) => {
|
|
stubMinimalEnv({FLUXER_TAG_STYLE: raw});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_TAG_STYLE must be none or random');
|
|
});
|
|
|
|
test('rejects an unknown account identity', async () => {
|
|
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: 'phone'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_ACCOUNT_IDENTITY must be email or username');
|
|
});
|
|
|
|
test('leaves both stores off with no apps, packages or products by default', async () => {
|
|
stubMinimalEnv();
|
|
const config = await loadConfig();
|
|
expect(config.integrations.app_store).toEqual({enabled: false, apps: [], products: {}});
|
|
expect(config.integrations.google_play).toEqual({
|
|
enabled: false,
|
|
packages: [],
|
|
token_uri: 'https://oauth2.googleapis.com/token',
|
|
products: {},
|
|
});
|
|
expect(config.integrations.store_billing).toEqual({sandbox_user_ids: [], sandbox_entitles_all: false});
|
|
});
|
|
|
|
test('loads a valid store catalogue', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_APP_STORE_ENABLED: 'true',
|
|
FLUXER_APP_STORE_APPS: '[{"bundle_id":"com.fluxer","app_apple_id":1234567890}]',
|
|
FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.plutonium.monthly":"monthly","com.fluxer.gift.1month":"gift_1_month"}',
|
|
FLUXER_GOOGLE_PLAY_ENABLED: 'true',
|
|
FLUXER_GOOGLE_PLAY_PACKAGES: 'com.fluxer',
|
|
FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:yearly":"yearly","gift_1_year":"gift_1_year"}',
|
|
FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '1234567890123456',
|
|
});
|
|
const config = await loadConfig();
|
|
expect(config.integrations.app_store.apps).toEqual([{bundle_id: 'com.fluxer', app_apple_id: 1234567890}]);
|
|
expect(config.integrations.google_play.products).toEqual({
|
|
'plutonium:yearly': 'yearly',
|
|
gift_1_year: 'gift_1_year',
|
|
});
|
|
expect(config.integrations.store_billing.sandbox_user_ids).toEqual(['1234567890123456']);
|
|
});
|
|
|
|
test('rejects an App Store app without a numeric app_apple_id', async () => {
|
|
for (const apps of [
|
|
'[{"bundle_id":"com.fluxer"}]',
|
|
'[{"bundle_id":"com.fluxer","app_apple_id":"1234567890"}]',
|
|
'[{"bundle_id":"com.fluxer","app_apple_id":0}]',
|
|
]) {
|
|
resetConfig();
|
|
stubMinimalEnv({FLUXER_APP_STORE_APPS: apps});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a numeric app_apple_id');
|
|
}
|
|
});
|
|
|
|
test('rejects an App Store app without a bundle id', async () => {
|
|
stubMinimalEnv({FLUXER_APP_STORE_APPS: '[{"app_apple_id":1234567890}]'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a bundle_id');
|
|
});
|
|
|
|
test('rejects an App Store product mapped to an unknown slot', async () => {
|
|
stubMinimalEnv({FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.visionary":"visionary"}'});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'Invalid FLUXER_APP_STORE_PRODUCTS slot for com.fluxer.visionary: visionary',
|
|
);
|
|
});
|
|
|
|
test('rejects a Google Play product mapped to an unknown slot', async () => {
|
|
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:weekly":"weekly"}'});
|
|
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_GOOGLE_PLAY_PRODUCTS slot for plutonium:weekly: weekly');
|
|
});
|
|
|
|
test('rejects a Google Play product key that does not fit its slot kind', async () => {
|
|
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium":"monthly"}'});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_GOOGLE_PLAY_PRODUCTS key plutonium does not match its monthly slot',
|
|
);
|
|
resetConfig();
|
|
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"gift:one":"gift_1_month"}'});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_GOOGLE_PLAY_PRODUCTS key gift:one does not match its gift_1_month slot',
|
|
);
|
|
});
|
|
|
|
test('rejects a sandbox user id that is not a snowflake', async () => {
|
|
stubMinimalEnv({FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '123,abc'});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_STORE_BILLING_SANDBOX_USER_IDS must be a comma separated list of user ids',
|
|
);
|
|
});
|
|
|
|
test('defaults the cache purge adapter to none', async () => {
|
|
stubMinimalEnv();
|
|
expect((await loadConfig()).integrations.cache_purge).toEqual({
|
|
adapter: 'none',
|
|
http: {endpoint: '', token: '', timeout_ms: 10_000},
|
|
});
|
|
});
|
|
|
|
test('reads the http cache purge settings from the environment', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_ADAPTER: 'http',
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purge.internal/purge',
|
|
FLUXER_CACHE_PURGE_HTTP_TOKEN: 'purge-token',
|
|
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: '5000',
|
|
});
|
|
expect((await loadConfig()).integrations.cache_purge).toEqual({
|
|
adapter: 'http',
|
|
http: {endpoint: 'https://purge.internal/purge', token: 'purge-token', timeout_ms: 5000},
|
|
});
|
|
});
|
|
|
|
test('rejects an unknown cache purge adapter', async () => {
|
|
stubMinimalEnv({FLUXER_CACHE_PURGE_ADAPTER: 'varnish'});
|
|
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_CACHE_PURGE_ADAPTER: varnish');
|
|
});
|
|
|
|
test('rejects the http cache purge adapter without an endpoint', async () => {
|
|
stubMinimalEnv({FLUXER_CACHE_PURGE_ADAPTER: 'http'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_CACHE_PURGE_HTTP_ENDPOINT is required');
|
|
});
|
|
|
|
test('rejects the http cache purge adapter without a token', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_ADAPTER: 'http',
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purge.internal/purge',
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_CACHE_PURGE_HTTP_TOKEN is required');
|
|
});
|
|
|
|
test('rejects a cache purge endpoint that is not an absolute http URL', async () => {
|
|
for (const endpoint of ['/purge', 'purge.internal/purge', 'ftp://purge.internal/purge']) {
|
|
stubMinimalEnv({FLUXER_CACHE_PURGE_ADAPTER: 'http', FLUXER_CACHE_PURGE_HTTP_ENDPOINT: endpoint});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_CACHE_PURGE_HTTP_ENDPOINT must be an absolute http or https URL without credentials',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects a cache purge endpoint that contains credentials', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_ADAPTER: 'http',
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purger:[email protected]/purge',
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_CACHE_PURGE_HTTP_ENDPOINT must be an absolute http or https URL without credentials',
|
|
);
|
|
});
|
|
|
|
test('rejects a cache purge timeout outside 1000 to 10000', async () => {
|
|
for (const timeout of ['999', '10001']) {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_ADAPTER: 'http',
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purge.internal/purge',
|
|
FLUXER_CACHE_PURGE_HTTP_TOKEN: 'purge-token',
|
|
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: timeout,
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS must be an integer between 1000 and 10000',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects a cache purge token with spaces or control characters without echoing it', async () => {
|
|
for (const token of ['secret\nvalue', 'secret\rvalue', 'secret value', 'secret-value\n']) {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_ADAPTER: 'http',
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purge.internal/purge',
|
|
FLUXER_CACHE_PURGE_HTTP_TOKEN: token,
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
/^FLUXER_CACHE_PURGE_HTTP_TOKEN must contain only visible ASCII characters$/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('leaves the cache purge settings unvalidated when the adapter is none', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'ftp://purge.internal/purge',
|
|
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: '1',
|
|
});
|
|
expect((await loadConfig()).integrations.cache_purge.adapter).toBe('none');
|
|
});
|
|
|
|
test('leaves the optional outbound lookups unset by default', async () => {
|
|
stubMinimalEnv();
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.breached_password_check.enabled).toBeUndefined();
|
|
});
|
|
|
|
test('reads the optional outbound lookup switches from the environment', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: 'false',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.breached_password_check.enabled).toBe(false);
|
|
});
|
|
|
|
test('leaves Bluesky login off with no legal URLs by default', async () => {
|
|
stubMinimalEnv();
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.bluesky.enabled).toBe(false);
|
|
expect(config.auth.bluesky.tos_uri).toBe('');
|
|
expect(config.auth.bluesky.policy_uri).toBe('');
|
|
expect(config.auth.bluesky.keys).toEqual([]);
|
|
});
|
|
|
|
test('applies explicit Bluesky legal URLs from the environment', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_AUTH_BLUESKY_ENABLED: 'true',
|
|
FLUXER_AUTH_BLUESKY_TOS_URI: 'https://chat.example.com/terms',
|
|
FLUXER_AUTH_BLUESKY_POLICY_URI: 'https://chat.example.com/privacy',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.bluesky.enabled).toBe(true);
|
|
expect(config.auth.bluesky.tos_uri).toBe('https://chat.example.com/terms');
|
|
expect(config.auth.bluesky.policy_uri).toBe('https://chat.example.com/privacy');
|
|
});
|
|
|
|
test('reads the upload relay secret through the override table', async () => {
|
|
const secret = Buffer.alloc(32, 9).toString('base64');
|
|
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: secret});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.services.media_proxy.upload_relay.secret_base64).toBe(secret);
|
|
});
|
|
|
|
test('defaults the upload relay body limit to the media proxy ceiling', async () => {
|
|
stubMinimalEnv();
|
|
expect((await loadConfig()).services.media_proxy.upload_relay.max_body_bytes).toBe(524_288_000);
|
|
});
|
|
|
|
test('rejects a missing upload relay secret', async () => {
|
|
stubMinimalEnv();
|
|
vi.stubEnv('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', '');
|
|
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required');
|
|
});
|
|
|
|
test('rejects a non-base64 upload relay secret', async () => {
|
|
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'not base64!'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64');
|
|
});
|
|
|
|
test('rejects an upload relay secret shorter than 32 bytes', async () => {
|
|
stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: Buffer.alloc(16, 7).toString('base64')});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes',
|
|
);
|
|
});
|
|
|
|
test('rejects a VAPID public key that is not a 65-byte uncompressed point', async () => {
|
|
const {privateKey} = generateVapidPair();
|
|
stubMinimalEnv({
|
|
FLUXER_VAPID_PUBLIC_KEY: Buffer.alloc(64, 4).toString('base64url'),
|
|
FLUXER_VAPID_PRIVATE_KEY: privateKey,
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_VAPID_PUBLIC_KEY must be the base64url 65-byte uncompressed P-256 point',
|
|
);
|
|
});
|
|
|
|
test('rejects a VAPID private key that is not a 32-byte scalar', async () => {
|
|
const {publicKey} = generateVapidPair();
|
|
stubMinimalEnv({
|
|
FLUXER_VAPID_PUBLIC_KEY: publicKey,
|
|
FLUXER_VAPID_PRIVATE_KEY: Buffer.alloc(31, 9).toString('base64url'),
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY must be the base64url 32-byte P-256 scalar');
|
|
});
|
|
|
|
test('rejects a well formed VAPID scalar that does not derive the public point', async () => {
|
|
const {publicKey} = generateVapidPair();
|
|
const other = generateVapidPair();
|
|
stubMinimalEnv({
|
|
FLUXER_VAPID_PUBLIC_KEY: publicKey,
|
|
FLUXER_VAPID_PRIVATE_KEY: other.privateKey,
|
|
});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY');
|
|
});
|
|
|
|
test('accepts a generated VAPID pair', async () => {
|
|
const pair = generateVapidPair();
|
|
stubMinimalEnv({
|
|
FLUXER_VAPID_PUBLIC_KEY: pair.publicKey,
|
|
FLUXER_VAPID_PRIVATE_KEY: pair.privateKey,
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.auth.vapid.public_key).toBe(pair.publicKey);
|
|
expect(config.auth.vapid.private_key).toBe(pair.privateKey);
|
|
});
|
|
|
|
test('requires a complete environment', async () => {
|
|
vi.stubEnv('FLUXER_ENV', 'test');
|
|
await expect(loadConfig()).rejects.toThrow();
|
|
});
|
|
|
|
test('inserts the public port into the LiveKit url', async () => {
|
|
stubMinimalEnv({FLUXER_LIVEKIT_URL: 'http://localhost/livekit'});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.integrations.voice.url).toBe('http://localhost:8088/livekit');
|
|
});
|
|
|
|
test('inserts the public port into every other public url the config contains', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_S3_PUBLIC_ENDPOINT: 'http://localhost/s3',
|
|
FLUXER_EMAIL_APP_BASE_URL: 'http://localhost',
|
|
FLUXER_AUTH_BLUESKY_CLIENT_URI: 'http://localhost',
|
|
FLUXER_AUTH_BLUESKY_TOS_URI: 'http://localhost/terms',
|
|
FLUXER_APP_ICON_URL: 'http://localhost/icon.png',
|
|
FLUXER_LIVEKIT_INTERNAL_URL: 'http://livekit:7880',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.s3?.presigned_url_base).toBe('http://localhost:8088/s3');
|
|
expect(config.integrations.email.app_base_url).toBe('http://localhost:8088');
|
|
expect(config.auth.bluesky.client_uri).toBe('http://localhost:8088');
|
|
expect(config.auth.bluesky.tos_uri).toBe('http://localhost:8088/terms');
|
|
expect(config.instance.branding.icon_url).toBe('http://localhost:8088/icon.png');
|
|
expect(config.integrations.voice.internal_url).toBe('http://livekit:7880');
|
|
});
|
|
|
|
test('rejects a public port outside the valid range', async () => {
|
|
stubMinimalEnv({FLUXER_PUBLIC_PORT: '70000'});
|
|
await expect(loadConfig()).rejects.toThrow('FLUXER_PUBLIC_PORT must be an integer between 1 and 65535');
|
|
});
|
|
});
|
|
|
|
describe('FLUXER_PUBLIC_ORIGIN', () => {
|
|
beforeEach(() => {
|
|
resetConfig();
|
|
clearFluxerEnv();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetConfig();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
test('a ported origin ports every derived endpoint and every compose override', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
|
|
FLUXER_MARKETING_ENDPOINT: 'https://chat.example.com:29080',
|
|
FLUXER_MEDIA_ENDPOINT: 'https://chat.example.com:29080/media',
|
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example.com:29080/media',
|
|
FLUXER_LIVEKIT_URL: 'https://chat.example.com:29080/livekit',
|
|
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example.com:29080',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.public_port).toBe(29080);
|
|
expect(config.domain.public_origin).toBe('https://chat.example.com:29080');
|
|
expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api');
|
|
expect(config.endpoints.app).toBe('https://chat.example.com:29080');
|
|
expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
|
|
expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin');
|
|
expect(config.endpoints.marketing).toBe('https://chat.example.com:29080');
|
|
expect(config.endpoints.media).toBe('https://chat.example.com:29080/media');
|
|
expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example.com:29080/media');
|
|
expect(config.integrations.voice.url).toBe('https://chat.example.com:29080/livekit');
|
|
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example.com:29080']);
|
|
});
|
|
|
|
test('the origin port wins over a FLUXER_PUBLIC_PORT that disagrees', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.public_port).toBe(29080);
|
|
expect(config.endpoints.app).toBe('https://chat.example.com:29080');
|
|
expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api');
|
|
expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
|
|
expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin');
|
|
});
|
|
|
|
test('accepts an origin whose port matches FLUXER_PUBLIC_PORT', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '29080',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
|
|
});
|
|
|
|
expect((await loadConfig()).endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
|
|
});
|
|
|
|
test('normalizes an origin written with an explicit default port', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '443',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:443',
|
|
FLUXER_ADMIN_ENDPOINT: 'https://chat.example.com/admin',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.public_port).toBe(443);
|
|
expect(config.domain.public_origin).toBe('https://chat.example.com');
|
|
expect(config.endpoints.admin).toBe('https://chat.example.com/admin');
|
|
expect(config.endpoints.app).toBe('https://chat.example.com');
|
|
expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway');
|
|
});
|
|
|
|
test('takes the scheme and the domain from the origin when neither is set', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: '',
|
|
FLUXER_PUBLIC_SCHEME: '',
|
|
FLUXER_PUBLIC_PORT: '',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.base_domain).toBe('chat.example.com');
|
|
expect(config.domain.public_scheme).toBe('https');
|
|
expect(config.domain.public_port).toBe(29080);
|
|
});
|
|
|
|
test('the origin scheme wins over a FLUXER_PUBLIC_SCHEME that disagrees', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'http',
|
|
FLUXER_PUBLIC_PORT: '',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.public_scheme).toBe('https');
|
|
expect(config.endpoints.app).toBe('https://chat.example.com');
|
|
expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway');
|
|
});
|
|
|
|
test('the origin host wins over a FLUXER_BASE_DOMAIN that disagrees', async () => {
|
|
stubMinimalEnv({
|
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
|
FLUXER_PUBLIC_SCHEME: 'https',
|
|
FLUXER_PUBLIC_PORT: '',
|
|
FLUXER_PUBLIC_ORIGIN: 'https://other.example.com',
|
|
});
|
|
|
|
const config = await loadConfig();
|
|
|
|
expect(config.domain.base_domain).toBe('other.example.com');
|
|
expect(config.endpoints.app).toBe('https://other.example.com');
|
|
});
|
|
|
|
test('refuses to boot on an origin that is not a bare origin', async () => {
|
|
stubMinimalEnv({FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com/app'});
|
|
await expect(loadConfig()).rejects.toThrow(
|
|
'FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443',
|
|
);
|
|
});
|
|
|
|
test('leaves the derived origin canonical when nothing is set', async () => {
|
|
stubMinimalEnv();
|
|
expect((await loadConfig()).domain.public_origin).toBe('http://localhost:8088');
|
|
});
|
|
});
|