import { mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isConfigData } from '@/lib/config-validation'; // The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default // data/admin) before env vars. Point it at an empty temp dir so local admin // state on the developer's machine can't leak into these env-driven // assertions. Set directly (not via vi.stubEnv) so unstubAllEnvs() between // tests can't clear it - the config-manager singleton caches it on first load. process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-')); // Mock NextResponse before importing the route vi.mock('next/server', () => ({ NextResponse: { json: (data: unknown, init?: { headers?: HeadersInit }) => ({ json: async () => data, headers: new Headers(init?.headers), }), }, })); vi.mock('@/lib/logger', () => ({ logger: { debug: vi.fn() }, })); // Env vars these tests drive. Stubbed to undefined before each test so the // developer's / CI's real environment (and any other test file sharing this // worker's process.env) can't leak in. const MANAGED_ENV = [ 'APP_NAME', 'NEXT_PUBLIC_APP_NAME', 'JMAP_SERVER_URL', 'NEXT_PUBLIC_JMAP_SERVER_URL', 'OAUTH_ENABLED', 'OAUTH_CLIENT_ID', 'OAUTH_ISSUER_URL', 'SESSION_SECRET', 'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP', 'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME', 'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING', 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL', 'LOGIN_SHOW_REMEMBER_ME', ] as const; describe('config API route', () => { // Unique per-test dirs so the SESSION_SECRET_FILE tests never share a path; // removed synchronously in afterEach (the old fire-and-forget async unlink of // a shared ./session-secret raced across tests under the full suite). const tempDirs: string[] = []; function secretFile(contents = 'test-secret'): string { const dir = mkdtempSync(path.join(tmpdir(), 'bw-secret-')); tempDirs.push(dir); const file = path.join(dir, 'session-secret'); writeFileSync(file, contents); return file; } beforeEach(() => { for (const key of MANAGED_ENV) vi.stubEnv(key, undefined); }); afterEach(() => { vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); function mockRequest(headers: Record = {}): unknown { const lc: Record = {}; for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v; return { headers: { get(name: string) { return lc[name.toLowerCase()] ?? null; }, }, }; } async function getConfig(headers?: Record) { // Re-import to pick up env changes const { GET } = await import('@/app/api/config/route'); const response = await GET(mockRequest(headers) as Parameters[0]); const config = await response.json(); expect(isConfigData(config)).toBe(true); return config; } it('prevents caching host-specific runtime configuration', async () => { const { GET } = await import('@/app/api/config/route'); const response = await GET(mockRequest() as Parameters[0]); expect(response.headers.get('Cache-Control')).toContain('no-store'); expect(response.headers.get('CDN-Cache-Control')).toBe('no-store'); expect(response.headers.get('Vary')).toBe('Host, X-Forwarded-Host'); }); it('should return defaults when no env vars are set', async () => { const config = await getConfig(); expect(config.appName).toBe('Webmail'); expect(config.jmapServerUrl).toBe(''); expect(config.oauthEnabled).toBe(false); expect(config.oauthClientId).toBe(''); expect(config.oauthIssuerUrl).toBe(''); expect(config.rememberMeEnabled).toBe(false); expect(config.settingsSyncEnabled).toBe(false); expect(config.stalwartFeaturesEnabled).toBe(true); expect(config.devMode).toBe(false); expect(config.loginCompanyName).toBe(''); expect(config.loginImprintUrl).toBe(''); expect(config.loginPrivacyPolicyUrl).toBe(''); expect(config.loginWebsiteUrl).toBe(''); expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg'); expect(config.appLogoLightUrl).toBe(''); expect(config.appLogoDarkUrl).toBe(''); expect(config.sourceCodeUrl).toBe(''); }); // The domain lists name every organisation served by this instance. it('lists the domains of each server only when the login page picks servers by domain', async () => { vi.stubEnv('JMAP_SERVERS', JSON.stringify([{ id: 'a', label: 'A', url: 'https://a.example', domains: ['acme.example'] }])); expect((await getConfig()).jmapServers[0].domains).toEqual([]); vi.stubEnv('JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'true'); expect((await getConfig()).jmapServers[0].domains).toEqual(['acme.example']); }); it('should use runtime env vars over defaults', async () => { vi.stubEnv('APP_NAME', 'My Mail'); vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com'); const config = await getConfig(); expect(config.appName).toBe('My Mail'); expect(config.jmapServerUrl).toBe('https://mail.example.com'); }); it('should fall back to NEXT_PUBLIC_ vars when runtime vars are unset', async () => { vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'Legacy Mail'); vi.stubEnv('NEXT_PUBLIC_JMAP_SERVER_URL', 'https://legacy.example.com'); const config = await getConfig(); expect(config.appName).toBe('Legacy Mail'); expect(config.jmapServerUrl).toBe('https://legacy.example.com'); }); it('should prefer runtime vars over NEXT_PUBLIC_ vars', async () => { vi.stubEnv('APP_NAME', 'Runtime'); vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'BuildTime'); const config = await getConfig(); expect(config.appName).toBe('Runtime'); }); it('should return login page customization values', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Acme Corp'); vi.stubEnv('LOGIN_IMPRINT_URL', 'https://acme.com/imprint'); vi.stubEnv('LOGIN_PRIVACY_POLICY_URL', 'https://acme.com/privacy'); vi.stubEnv('LOGIN_WEBSITE_URL', 'https://acme.com'); const config = await getConfig(); expect(config.loginCompanyName).toBe('Acme Corp'); expect(config.loginImprintUrl).toBe('https://acme.com/imprint'); expect(config.loginPrivacyPolicyUrl).toBe('https://acme.com/privacy'); expect(config.loginWebsiteUrl).toBe('https://acme.com'); }); it('publishes the source code URL for the About card (#1161)', async () => { vi.stubEnv('SOURCE_CODE_URL', 'https://git.example.com/acme/webmail/tree/v1.2.3'); expect((await getConfig()).sourceCodeUrl).toBe('https://git.example.com/acme/webmail/tree/v1.2.3'); }); it.each(['javascript:alert(1)', 'data:text/html,x', '/relative/path', 'not a url'])( 'drops a source code URL that is not absolute http(s): %s', async (value) => { vi.stubEnv('SOURCE_CODE_URL', value); expect((await getConfig()).sourceCodeUrl).toBe(''); }, ); it('should handle partial login customization', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp'); // Leave URLs unset const config = await getConfig(); expect(config.loginCompanyName).toBe('Partial Corp'); expect(config.loginImprintUrl).toBe(''); expect(config.loginPrivacyPolicyUrl).toBe(''); expect(config.loginWebsiteUrl).toBe(''); }); it('should enable rememberMe when SESSION_SECRET is set', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); const config = await getConfig(); expect(config.rememberMeEnabled).toBe(true); }); it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => { vi.stubEnv('SESSION_SECRET_FILE', secretFile()); const config = await getConfig(); expect(config.rememberMeEnabled).toBe(true); }); it('hides the remember-me box unless LOGIN_SHOW_REMEMBER_ME turns it on', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); expect((await getConfig()).loginShowRememberMe).toBe(false); vi.stubEnv('LOGIN_SHOW_REMEMBER_ME', 'true'); expect((await getConfig()).loginShowRememberMe).toBe(true); }); it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => { vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true'); const config1 = await getConfig(); expect(config1.settingsSyncEnabled).toBe(false); vi.stubEnv('SESSION_SECRET', 'test-secret'); const config2 = await getConfig(); expect(config2.settingsSyncEnabled).toBe(true); }); it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => { vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true'); const config1 = await getConfig(); expect(config1.settingsSyncEnabled).toBe(false); vi.stubEnv('SESSION_SECRET_FILE', secretFile()); const config2 = await getConfig(); expect(config2.settingsSyncEnabled).toBe(true); }); it('should disable stalwart features when explicitly set to false', async () => { vi.stubEnv('STALWART_FEATURES', 'false'); const config = await getConfig(); expect(config.stalwartFeaturesEnabled).toBe(false); }); it('should return custom favicon and app logo URLs', async () => { vi.stubEnv('FAVICON_URL', '/branding/custom-favicon.svg'); vi.stubEnv('APP_LOGO_LIGHT_URL', '/branding/my-logo.svg'); vi.stubEnv('APP_LOGO_DARK_URL', '/branding/my-logo-white.svg'); const config = await getConfig(); expect(config.faviconUrl).toBe('/branding/custom-favicon.svg'); expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg'); expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg'); }); describe('per-domain branding overrides', () => { it('applies overrides for the matching host', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co'); vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example'); vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'mail1.example.com', loginCompanyName: 'Brand One', loginWebsiteUrl: 'https://one.example', }, ])); const config = await getConfig({ host: 'mail1.example.com' }); expect(config.loginCompanyName).toBe('Brand One'); expect(config.loginWebsiteUrl).toBe('https://one.example'); }); it('falls through to the global value when the host has no entry', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co'); vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, ])); const config = await getConfig({ host: 'unmapped.example.com' }); expect(config.loginCompanyName).toBe('Default Co'); }); it('falls through field-by-field when the matching entry omits a field', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co'); vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example'); vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, ])); const config = await getConfig({ host: 'mail1.example.com' }); expect(config.loginCompanyName).toBe('Brand One'); expect(config.loginWebsiteUrl).toBe('https://default.example'); }); it('prefers X-Forwarded-Host over Host', async () => { vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'public.example.com', loginCompanyName: 'Public' }, ])); const config = await getConfig({ host: 'internal.example.com', 'x-forwarded-host': 'public.example.com', }); expect(config.loginCompanyName).toBe('Public'); }); it('strips the port from the host header before matching', async () => { vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, ])); const config = await getConfig({ host: 'mail1.example.com:8443' }); expect(config.loginCompanyName).toBe('Brand One'); }); }); });