From 9c084d9d63890828d291f7c26d4f7b77a0c17e3e Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:45:02 +0200 Subject: [PATCH 1/2] fix(calendar): hide tasks-only calendars from the event calendar UI A calendar holding only tasks (VTODO, e.g. from tasks.org) appeared in the event calendar UI as an empty calendar. Stalwart's Calendar/get exposes no supported-component set, so tasks-only-ness is derived from the calendar's objects (mirroring the existing event/task split): a calendar is tasks-only iff it holds >=1 object and every object is a task. - getTasksOnlyCalendarIds scans an account's objects (CalendarEvent/query + minimal /get), early-exits once every calendar has an event, only marks after a full scan, and fails open on the cap or any error. - getAllCalendars/getCalendars annotate calendars with isTasksOnly. - Such calendars stay in the store for the tasks view (task picker, task-list grouping) but are filtered from the event sidebar and default selection. Verified against Stalwart v0.16.16: the tasks-only calendar is flagged, the default (with an event) is not. Closes #761. --- .../calendar/calendar-sidebar-panel.tsx | 14 ++-- .../calendar-component-detection.test.ts | 68 +++++++++++++++++++ lib/calendar-component-detection.ts | 66 ++++++++++++++++++ lib/jmap/client.ts | 62 ++++++++++++++++- lib/jmap/types.ts | 4 ++ stores/calendar-store.ts | 5 +- 6 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 lib/__tests__/calendar-component-detection.test.ts create mode 100644 lib/calendar-component-detection.ts diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 7dd844510..5529f046c 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -132,9 +132,13 @@ export function CalendarSidebarPanel({ const localAccounts = useAccountStore((s) => s.accounts); const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); - const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); + // Tasks-only calendars (VTODO, no events) are kept in the store for the tasks + // view but hidden from the event calendar list here. + const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]); + + const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]); const sharedAccountGroups = useMemo(() => { - const shared = calendars.filter(c => c.isShared); + const shared = eventCalendars.filter(c => c.isShared); const groups = new Map(); for (const cal of shared) { const key = cal.accountId || cal.accountName || cal.id; @@ -144,7 +148,7 @@ export function CalendarSidebarPanel({ groups.get(key)!.calendars.push(cal); } return Array.from(groups.values()); - }, [calendars]); + }, [eventCalendars]); /** * Pro / multi-account grouping: every calendar bucketed by its owning @@ -156,7 +160,7 @@ export function CalendarSidebarPanel({ const localAccountGroups = useMemo(() => { if (!multiAccountMode) return []; const byAccount = new Map(); - for (const cal of calendars) { + for (const cal of eventCalendars) { const key = cal.localAccountId || '__other__'; const list = byAccount.get(key) ?? []; list.push(cal); @@ -191,7 +195,7 @@ export function CalendarSidebarPanel({ ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) }); } return ordered; - }, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]); + }, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]); const getSubscriptionForCalendar = (calendarId: string) => { return icalSubscriptions.find(s => s.calendarId === calendarId); diff --git a/lib/__tests__/calendar-component-detection.test.ts b/lib/__tests__/calendar-component-detection.test.ts new file mode 100644 index 000000000..f4be76b25 --- /dev/null +++ b/lib/__tests__/calendar-component-detection.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { isTaskLikeObject, findTasksOnlyCalendarIds, type ScannedCalendarObject } from '@/lib/calendar-component-detection'; + +describe('isTaskLikeObject', () => { + it('treats an explicit @type Task as a task', () => { + expect(isTaskLikeObject({ '@type': 'Task' })).toBe(true); + expect(isTaskLikeObject({ '@type': 'task' })).toBe(true); + }); + + it('treats an @type Event as not a task even with a stray field', () => { + expect(isTaskLikeObject({ '@type': 'Event' })).toBe(false); + expect(isTaskLikeObject({ '@type': 'Event', due: '2026-01-01T00:00:00' })).toBe(false); + }); + + it('detects a CalDAV task lacking @type by its task-only fields', () => { + expect(isTaskLikeObject({ due: '2026-01-01T00:00:00' })).toBe(true); + expect(isTaskLikeObject({ progress: 'needs-action' })).toBe(true); + expect(isTaskLikeObject({ percentComplete: 0 })).toBe(true); + }); + + it('treats a plain object with no task markers as an event', () => { + expect(isTaskLikeObject({})).toBe(false); + expect(isTaskLikeObject({ '@type': 'Event', title: 'Standup' })).toBe(false); + }); +}); + +describe('findTasksOnlyCalendarIds', () => { + it('flags a calendar whose objects are all tasks', () => { + const objects: ScannedCalendarObject[] = [ + { '@type': 'Task', calendarIds: { 'cal-tasks': true } }, + { due: '2026-01-01T00:00:00', calendarIds: { 'cal-tasks': true } }, + ]; + expect([...findTasksOnlyCalendarIds(objects, ['cal-tasks'])]).toEqual(['cal-tasks']); + }); + + it('does not flag a calendar that has at least one event', () => { + const objects: ScannedCalendarObject[] = [ + { '@type': 'Task', calendarIds: { 'cal-mixed': true } }, + { '@type': 'Event', calendarIds: { 'cal-mixed': true } }, + ]; + expect(findTasksOnlyCalendarIds(objects, ['cal-mixed']).size).toBe(0); + }); + + it('does not flag an empty calendar (no objects)', () => { + expect(findTasksOnlyCalendarIds([], ['cal-empty']).size).toBe(0); + const objects: ScannedCalendarObject[] = [{ '@type': 'Event', calendarIds: { 'cal-other': true } }]; + expect(findTasksOnlyCalendarIds(objects, ['cal-empty']).size).toBe(0); + }); + + it('classifies each calendar independently in a mixed account', () => { + const objects: ScannedCalendarObject[] = [ + { '@type': 'Event', calendarIds: { work: true } }, + { '@type': 'Task', calendarIds: { todos: true } }, + { percentComplete: 50, calendarIds: { todos: true } }, + ]; + const result = findTasksOnlyCalendarIds(objects, ['work', 'todos', 'empty']); + expect([...result]).toEqual(['todos']); + }); + + it('handles an object that belongs to several calendars', () => { + // An event shared into two calendars keeps both off the tasks-only list. + const objects: ScannedCalendarObject[] = [ + { '@type': 'Event', calendarIds: { a: true, b: true } }, + { '@type': 'Task', calendarIds: { b: true } }, + ]; + expect(findTasksOnlyCalendarIds(objects, ['a', 'b']).size).toBe(0); + }); +}); diff --git a/lib/calendar-component-detection.ts b/lib/calendar-component-detection.ts new file mode 100644 index 000000000..8e9e123c2 --- /dev/null +++ b/lib/calendar-component-detection.ts @@ -0,0 +1,66 @@ +/** + * Helpers for deciding whether a calendar holds only tasks (VTODO) and so + * should be hidden from the *event* calendar UI (#761). + * + * JMAP's Calendar/get on Stalwart exposes no per-calendar supported-component + * set, so "tasks-only" has to be derived from the objects a calendar contains, + * mirroring how task objects are already told apart from events elsewhere. + */ + +/** + * A calendar object as seen with the minimal properties we scan. The index + * signature keeps it tolerant of the other fields the server returns (id, etc.). + */ +export interface ScannedCalendarObject { + '@type'?: string; + due?: unknown; + progress?: unknown; + percentComplete?: unknown; + calendarIds?: Record | null; + [key: string]: unknown; +} + +/** + * Is this calendar object a task rather than an event? Matches an explicit + * `@type: "Task"`, and CalDAV-created tasks (which may lack the type) by the + * presence of RFC 8984 ยง5.2 Task-only keys (`due` / `progress` / + * `percentComplete`) - a VEVENT never carries those. Mirrors the existing + * event/task split so classification stays consistent. + */ +export function isTaskLikeObject(obj: ScannedCalendarObject): boolean { + const type = obj['@type']; + if (typeof type === 'string' && type.toLowerCase() === 'task') return true; + if (type !== 'Event' && ( + ('progress' in obj && typeof obj.progress === 'string') || + ('due' in obj && obj.due != null) || + ('percentComplete' in obj) + )) return true; + return false; +} + +/** + * Given every object in an account and the calendar ids under consideration, + * return the ids of calendars that hold at least one object and whose objects + * are ALL tasks (no event). Empty calendars are deliberately NOT included - + * a brand-new event calendar with nothing in it must stay visible. + */ +export function findTasksOnlyCalendarIds( + objects: ScannedCalendarObject[], + calendarIds: string[], +): Set { + const withAny = new Set(); + const withEvent = new Set(); + for (const obj of objects) { + const task = isTaskLikeObject(obj); + const ids = obj.calendarIds ? Object.keys(obj.calendarIds) : []; + for (const id of ids) { + withAny.add(id); + if (!task) withEvent.add(id); + } + } + const tasksOnly = new Set(); + for (const id of calendarIds) { + if (withAny.has(id) && !withEvent.has(id)) tasksOnly.add(id); + } + return tasksOnly; +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 83c87b166..d8f8f874f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5,6 +5,7 @@ import { toWildcardQuery } from "./search-utils"; import { batched, itemsPerRequest } from "./request-limits"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; +import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection"; import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; /** @@ -4882,7 +4883,9 @@ export class JMAPClient implements IJMAPClient { ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { - return (response.methodResponses[0][1].list || []) as Calendar[]; + const calendars = (response.methodResponses[0][1].list || []) as Calendar[]; + const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, calendars.map((c) => c.id)); + return calendars.map((cal) => ({ ...cal, isTasksOnly: tasksOnly.has(cal.id) })); } return []; } catch (error) { @@ -4891,6 +4894,61 @@ export class JMAPClient implements IJMAPClient { } } + /** + * Ids of the given calendars that hold only tasks and no events, so the event + * calendar UI can hide them. Stalwart's Calendar/get exposes no + * supported-component set, so this is derived by scanning the objects. + * + * Cheap in the common case and safe on the edges: each page is classified as + * it arrives and, as soon as EVERY calendar has been seen to contain an event, + * the scan stops (a normal event account exits after one page). It only marks + * a calendar tasks-only after scanning ALL of that account's objects; if the + * account has more than the scan cap, it fails OPEN (marks nothing) rather + * than risk hiding a calendar whose events sit past the cap. Any error also + * fails open. Empty calendars are never marked (they must stay visible). + */ + private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise> { + if (rawCalendarIds.length === 0) return new Set(); + + const QUERY_PAGE = 500; + const MAX_SCAN = 5000; // safety bound; beyond this we fail open + const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds']; + const timeZone = getUserTimeZone(); + const objects: ScannedCalendarObject[] = []; + const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event + + try { + let exhausted = false; + for (let position = 0; position < MAX_SCAN;) { + const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; + if (timeZone) queryArgs.timeZone = timeZone; + const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing()); + if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open + const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || []; + if (pageIds.length === 0) { exhausted = true; break; } + + const getResp = await this.request([ + ["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"] + ], this.calendarUsing()); + const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[]; + for (const obj of list) { + objects.push(obj); + if (!isTaskLikeObject(obj) && obj.calendarIds) { + for (const id of Object.keys(obj.calendarIds)) remaining.delete(id); + } + } + // Every calendar has at least one event -> none can be tasks-only. + if (remaining.size === 0) return new Set(); + if (pageIds.length < QUERY_PAGE) { exhausted = true; break; } + position += pageIds.length; + } + if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing + return findTasksOnlyCalendarIds(objects, rawCalendarIds); + } catch { + return new Set(); // fail open + } + } + async getAllCalendars(): Promise { try { const allCalendars: Calendar[] = []; @@ -4909,6 +4967,7 @@ export class JMAPClient implements IJMAPClient { if (response.methodResponses?.[0]?.[0] === "Calendar/get") { const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[]; + const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, rawCalendars.map((c) => c.id)); const calendars = rawCalendars.map((cal) => ({ ...cal, id: isPrimary ? cal.id : `${accountId}:${cal.id}`, @@ -4916,6 +4975,7 @@ export class JMAPClient implements IJMAPClient { accountId, accountName: account?.name || (isPrimary ? this.username : accountId), isShared: !isPrimary, + isTasksOnly: tasksOnly.has(cal.id), })); allCalendars.push(...calendars); } diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 7ebdbfffb..b0b076fa6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -546,6 +546,10 @@ export interface Calendar { // shared calendar (see lib/shared-calendar-colors). When true, the override // wins over per-event colors so the whole shared calendar paints uniformly. colorIsLocalOverride?: boolean; + // True when the calendar holds only tasks (VTODO) and no events, so it is + // hidden from the event calendar UI while remaining available to the tasks + // view (#761). Undefined means "not determined" (treated as not tasks-only). + isTasksOnly?: boolean; } export interface CalendarRights { diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 5346bf1b5..ddd120fad 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -330,10 +330,13 @@ export const useCalendarStore = create()( const calendars = await client.getAllCalendars(); const { selectedCalendarIds } = get(); const stillValid = reconcileSelectedIds(selectedCalendarIds, calendars); + // Default the visible selection to event calendars only - tasks-only + // calendars stay out of the event grid. + const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id); set({ calendars, isLoading: false, - selectedCalendarIds: stillValid.length > 0 ? stillValid : calendars.map(c => c.id), + selectedCalendarIds: stillValid.length > 0 ? stillValid : defaultSelected, }); } catch (error) { debug.error('Failed to fetch calendars:', error); From 5fc32c4e89064304f8be14f301af1c0069b476d4 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:42:56 +0200 Subject: [PATCH 2/2] test(config-route): isolate env with vi.stubEnv, fix secret-file race The suite intermittently failed "settingsSync ... SESSION_SECRET_FILE" under full parallel load (passed in isolation). Two causes: - env vars were mutated directly on the shared process.env, so other test files in the same worker could leak in. Use vi.stubEnv + unstubAllEnvs so each test's env is isolated and auto-restored, and stub the managed vars to undefined in beforeEach for a clean baseline. - both SESSION_SECRET_FILE tests wrote a shared relative ./session-secret and removed it with a fire-and-forget async unlink; under load the pending unlink from the first test fired during the second (after write, before read), so the file was missing. Use a unique temp file per test, removed synchronously in afterEach. Verified: 17/17, stable across repeated runs and with hostile env (APP_NAME/SETTINGS_SYNC_ENABLED/SESSION_SECRET) set in the real environment. --- lib/__tests__/config-route.test.ts | 142 ++++++++++++++--------------- 1 file changed, 66 insertions(+), 76 deletions(-) diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 7c62ac59f..f2315e88f 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, unlink, writeFileSync } from "fs"; +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'; @@ -6,8 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // 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. Must happen before the first GET, because the config manager -// singleton loads the directory once and caches it. +// 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 @@ -21,35 +21,37 @@ 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', +] as const; + describe('config API route', () => { - const originalEnv = { ...process.env }; + // 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(() => { - // Clear all relevant env vars before each test - delete process.env.APP_NAME; - delete process.env.NEXT_PUBLIC_APP_NAME; - delete process.env.JMAP_SERVER_URL; - delete process.env.NEXT_PUBLIC_JMAP_SERVER_URL; - delete process.env.OAUTH_ENABLED; - delete process.env.OAUTH_CLIENT_ID; - delete process.env.OAUTH_ISSUER_URL; - delete process.env.SESSION_SECRET; - delete process.env.SESSION_SECRET_FILE; - delete process.env.SETTINGS_SYNC_ENABLED; - delete process.env.STALWART_FEATURES; - delete process.env.DEV_MOCK_JMAP; - delete process.env.FAVICON_URL; - delete process.env.APP_LOGO_LIGHT_URL; - delete process.env.APP_LOGO_DARK_URL; - delete process.env.LOGIN_COMPANY_NAME; - delete process.env.LOGIN_IMPRINT_URL; - delete process.env.LOGIN_PRIVACY_POLICY_URL; - delete process.env.LOGIN_WEBSITE_URL; - delete process.env.DOMAIN_BRANDING; + for (const key of MANAGED_ENV) vi.stubEnv(key, undefined); }); afterEach(() => { - process.env = { ...originalEnv }; + vi.unstubAllEnvs(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); function mockRequest(headers: Record = {}): unknown { @@ -93,8 +95,8 @@ describe('config API route', () => { }); it('should use runtime env vars over defaults', async () => { - process.env.APP_NAME = 'My Mail'; - process.env.JMAP_SERVER_URL = 'https://mail.example.com'; + vi.stubEnv('APP_NAME', 'My Mail'); + vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com'); const config = await getConfig(); @@ -103,8 +105,8 @@ describe('config API route', () => { }); it('should fall back to NEXT_PUBLIC_ vars when runtime vars are unset', async () => { - process.env.NEXT_PUBLIC_APP_NAME = 'Legacy Mail'; - process.env.NEXT_PUBLIC_JMAP_SERVER_URL = 'https://legacy.example.com'; + vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'Legacy Mail'); + vi.stubEnv('NEXT_PUBLIC_JMAP_SERVER_URL', 'https://legacy.example.com'); const config = await getConfig(); @@ -113,8 +115,8 @@ describe('config API route', () => { }); it('should prefer runtime vars over NEXT_PUBLIC_ vars', async () => { - process.env.APP_NAME = 'Runtime'; - process.env.NEXT_PUBLIC_APP_NAME = 'BuildTime'; + vi.stubEnv('APP_NAME', 'Runtime'); + vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'BuildTime'); const config = await getConfig(); @@ -122,10 +124,10 @@ describe('config API route', () => { }); it('should return login page customization values', async () => { - process.env.LOGIN_COMPANY_NAME = 'Acme Corp'; - process.env.LOGIN_IMPRINT_URL = 'https://acme.com/imprint'; - process.env.LOGIN_PRIVACY_POLICY_URL = 'https://acme.com/privacy'; - process.env.LOGIN_WEBSITE_URL = 'https://acme.com'; + 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(); @@ -136,7 +138,7 @@ describe('config API route', () => { }); it('should handle partial login customization', async () => { - process.env.LOGIN_COMPANY_NAME = 'Partial Corp'; + vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp'); // Leave URLs unset const config = await getConfig(); @@ -148,55 +150,43 @@ describe('config API route', () => { }); it('should enable rememberMe when SESSION_SECRET is set', async () => { - process.env.SESSION_SECRET = 'test-secret'; + 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 () => { - writeFileSync('./session-secret', 'test-secret'); - process.env.SESSION_SECRET_FILE = './session-secret'; + vi.stubEnv('SESSION_SECRET_FILE', secretFile()); const config = await getConfig(); - unlink('./session-secret', (err) => { - if (err) throw err; - }); - expect(config.rememberMeEnabled).toBe(true); }); it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => { - process.env.SETTINGS_SYNC_ENABLED = 'true'; + vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true'); const config1 = await getConfig(); expect(config1.settingsSyncEnabled).toBe(false); - process.env.SESSION_SECRET = 'test-secret'; + 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 () => { - process.env.SETTINGS_SYNC_ENABLED = 'true'; + vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true'); const config1 = await getConfig(); expect(config1.settingsSyncEnabled).toBe(false); - writeFileSync('./session-secret', 'test-secret'); - process.env.SESSION_SECRET_FILE = './session-secret'; - + vi.stubEnv('SESSION_SECRET_FILE', secretFile()); const config2 = await getConfig(); - - unlink('./session-secret', (err) => { - if (err) throw err; - }); - expect(config2.settingsSyncEnabled).toBe(true); }); it('should disable stalwart features when explicitly set to false', async () => { - process.env.STALWART_FEATURES = 'false'; + vi.stubEnv('STALWART_FEATURES', 'false'); const config = await getConfig(); @@ -204,9 +194,9 @@ describe('config API route', () => { }); it('should return custom favicon and app logo URLs', async () => { - process.env.FAVICON_URL = '/branding/custom-favicon.svg'; - process.env.APP_LOGO_LIGHT_URL = '/branding/my-logo.svg'; - process.env.APP_LOGO_DARK_URL = '/branding/my-logo-white.svg'; + 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(); @@ -217,15 +207,15 @@ describe('config API route', () => { describe('per-domain branding overrides', () => { it('applies overrides for the matching host', async () => { - process.env.LOGIN_COMPANY_NAME = 'Default Co'; - process.env.LOGIN_WEBSITE_URL = 'https://default.example'; - process.env.DOMAIN_BRANDING = JSON.stringify([ + 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' }); @@ -234,10 +224,10 @@ describe('config API route', () => { }); it('falls through to the global value when the host has no entry', async () => { - process.env.LOGIN_COMPANY_NAME = 'Default Co'; - process.env.DOMAIN_BRANDING = JSON.stringify([ + 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' }); @@ -245,11 +235,11 @@ describe('config API route', () => { }); it('falls through field-by-field when the matching entry omits a field', async () => { - process.env.LOGIN_COMPANY_NAME = 'Default Co'; - process.env.LOGIN_WEBSITE_URL = 'https://default.example'; - process.env.DOMAIN_BRANDING = JSON.stringify([ + 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' }); @@ -258,9 +248,9 @@ describe('config API route', () => { }); it('prefers X-Forwarded-Host over Host', async () => { - process.env.DOMAIN_BRANDING = JSON.stringify([ + vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'public.example.com', loginCompanyName: 'Public' }, - ]); + ])); const config = await getConfig({ host: 'internal.example.com', @@ -271,9 +261,9 @@ describe('config API route', () => { }); it('strips the port from the host header before matching', async () => { - process.env.DOMAIN_BRANDING = JSON.stringify([ + vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([ { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, - ]); + ])); const config = await getConfig({ host: 'mail1.example.com:8443' });