diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 85e07e2e..14a2ac21 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1561,16 +1561,26 @@ export const de: TranslationTree = { filePath: "Dateipfad:", color: "Farbe:", refreshMinutes: "Aktualisierung (Min):", + authentication: "Authentifizierung:", + username: "Benutzername:", + password: "Passwort:", }, typeOptions: { remote: "Remote URL", local: "Lokale Datei", }, + authOptions: { + none: "Keine (Öffentlich)", + basic: "HTTP Basic Auth", + }, + authWarning: "Anmeldedaten werden unverschlüsselt in den Plugin-Daten gespeichert. Verwende wenn möglich anwendungsspezifische Passwörter.", placeholders: { calendarName: "Kalendername", url: "ICS/iCal URL", filePath: "Lokaler Dateipfad (z.B. Kalender.ics)", localFile: "Kalender.ics", + username: "Benutzername", + password: "Passwort", }, statusLabels: { enabled: "Aktiviert", @@ -2578,6 +2588,7 @@ export const de: TranslationTree = { notices: { calendarNotFound: "Kalender \"{name}\" nicht gefunden (404). Bitte prüfe, ob die ICS-URL korrekt ist und der Kalender öffentlich zugänglich ist.", calendarAccessDenied: "Kalender \"{name}\" Zugriff verweigert (500). Dies könnte auf Microsoft Outlook Server-Beschränkungen zurückzuführen sein. Versuche, die ICS-URL aus deinen Kalendereinstellungen neu zu generieren.", + authenticationFailed: "Authentifizierung für Kalender \"{name}\" fehlgeschlagen. Bitte überprüfe deinen Benutzernamen und dein Passwort.", fetchRemoteFailed: "Remote-Kalender \"{name}\" konnte nicht abgerufen werden: {error}", readLocalFailed: "Lokaler Kalender \"{name}\" konnte nicht gelesen werden: {error}", }, diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 5194090d..959ae88e 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1599,16 +1599,26 @@ export const en: TranslationTree = { filePath: "File Path:", color: "Color:", refreshMinutes: "Refresh (min):", + authentication: "Authentication:", + username: "Username:", + password: "Password:", }, typeOptions: { remote: "Remote URL", local: "Local File", }, + authOptions: { + none: "None (Public)", + basic: "HTTP Basic Auth", + }, + authWarning: "Credentials are stored unencrypted in plugin data. Use app-specific passwords when available.", placeholders: { calendarName: "Calendar name", url: "ICS/iCal URL", filePath: "Local file path (e.g., Calendar.ics)", localFile: "Calendar.ics", + username: "Username", + password: "Password", }, statusLabels: { enabled: "Enabled", @@ -2638,6 +2648,8 @@ export const en: TranslationTree = { 'Calendar "{name}" not found (404). Please check the ICS URL is correct and the calendar is publicly accessible.', calendarAccessDenied: 'Calendar "{name}" access denied (500). This may be due to Microsoft Outlook server restrictions. Try regenerating the ICS URL from your calendar settings.', + authenticationFailed: + 'Authentication failed for calendar "{name}". Please check your username and password are correct.', fetchRemoteFailed: 'Failed to fetch remote calendar "{name}": {error}', readLocalFailed: 'Failed to read local calendar "{name}": {error}', }, diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 1e790e0e..f221f17c 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1561,16 +1561,26 @@ export const es: TranslationTree = { filePath: "Ruta de archivo:", color: "Color:", refreshMinutes: "Actualizar (min):", + authentication: "Autenticación:", + username: "Usuario:", + password: "Contraseña:", }, typeOptions: { remote: "URL remota", local: "Archivo local", }, + authOptions: { + none: "Ninguna (Público)", + basic: "HTTP Basic Auth", + }, + authWarning: "Las credenciales se almacenan sin cifrar en los datos del plugin. Usa contraseñas específicas de aplicación cuando estén disponibles.", placeholders: { calendarName: "Nombre del calendario", url: "URL ICS/iCal", filePath: "Ruta de archivo local (ej. Calendario.ics)", localFile: "Calendario.ics", + username: "Usuario", + password: "Contraseña", }, statusLabels: { enabled: "Habilitado", @@ -2578,6 +2588,7 @@ export const es: TranslationTree = { notices: { calendarNotFound: "Calendario \"{name}\" no encontrado (404). Por favor verifica que la URL ICS sea correcta y el calendario sea públicamente accesible.", calendarAccessDenied: "Acceso al calendario \"{name}\" denegado (500). Esto puede deberse a restricciones del servidor de Microsoft Outlook. Intenta regenerar la URL ICS desde la configuración de tu calendario.", + authenticationFailed: "Autenticación fallida para el calendario \"{name}\". Por favor verifica que tu usuario y contraseña sean correctos.", fetchRemoteFailed: "Error al obtener calendario remoto \"{name}\": {error}", readLocalFailed: "Error al leer calendario local \"{name}\": {error}", }, diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 9dd3897c..b886a356 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1561,16 +1561,26 @@ export const fr: TranslationTree = { filePath: "Chemin du fichier :", color: "Couleur :", refreshMinutes: "Actualisation (min) :", + authentication: "Authentification :", + username: "Nom d'utilisateur :", + password: "Mot de passe :", }, typeOptions: { remote: "URL distante", local: "Fichier local", }, + authOptions: { + none: "Aucune (Public)", + basic: "HTTP Basic Auth", + }, + authWarning: "Les identifiants sont stockés en clair dans les données du plugin. Utilisez des mots de passe spécifiques à l'application si disponibles.", placeholders: { calendarName: "Nom du calendrier", url: "URL ICS/iCal", filePath: "Chemin du fichier local (ex. Calendrier.ics)", localFile: "Calendrier.ics", + username: "Nom d'utilisateur", + password: "Mot de passe", }, statusLabels: { enabled: "Activé", @@ -2578,6 +2588,7 @@ export const fr: TranslationTree = { notices: { calendarNotFound: "Calendrier \"{name}\" introuvable (404). Veuillez vérifier que l'URL ICS est correcte et que le calendrier est accessible publiquement.", calendarAccessDenied: "Accès refusé au calendrier \"{name}\" (500). Cela peut être dû aux restrictions du serveur Microsoft Outlook. Essayez de régénérer l'URL ICS depuis les paramètres de votre calendrier.", + authenticationFailed: "Échec de l'authentification pour le calendrier \"{name}\". Veuillez vérifier votre nom d'utilisateur et mot de passe.", fetchRemoteFailed: "Échec de la récupération du calendrier distant \"{name}\" : {error}", readLocalFailed: "Échec de la lecture du calendrier local \"{name}\" : {error}", }, diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index b35d4ffa..09f6d47c 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1561,16 +1561,26 @@ export const ja: TranslationTree = { filePath: "ファイルパス:", color: "色:", refreshMinutes: "更新(分):", + authentication: "認証:", + username: "ユーザー名:", + password: "パスワード:", }, typeOptions: { remote: "リモートURL", local: "ローカルファイル", }, + authOptions: { + none: "なし(公開)", + basic: "HTTP Basic Auth", + }, + authWarning: "認証情報はプラグインデータに暗号化されずに保存されます。可能な場合はアプリ固有のパスワードを使用してください。", placeholders: { calendarName: "カレンダー名", url: "ICS/iCal URL", filePath: "ローカルファイルパス(例:Calendar.ics)", localFile: "Calendar.ics", + username: "ユーザー名", + password: "パスワード", }, statusLabels: { enabled: "有効", @@ -2578,6 +2588,7 @@ export const ja: TranslationTree = { notices: { calendarNotFound: "カレンダー\"{name}\"が見つかりません(404)。ICS URLが正しく、カレンダーが公開アクセス可能であることを確認してください。", calendarAccessDenied: "カレンダー\"{name}\"のアクセスが拒否されました(500)。これはMicrosoft Outlookサーバーの制限によるものかもしれません。カレンダー設定からICS URLを再生成してみてください。", + authenticationFailed: "カレンダー\"{name}\"の認証に失敗しました。ユーザー名とパスワードが正しいか確認してください。", fetchRemoteFailed: "リモートカレンダー\"{name}\"の取得に失敗しました:{error}", readLocalFailed: "ローカルカレンダー\"{name}\"の読み込みに失敗しました:{error}", }, diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 7d805def..6d9a5adb 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1563,17 +1563,27 @@ export const pt: TranslationTree = { url: "URL:", filePath: "Caminho do Arquivo:", color: "Cor:", - refreshMinutes: "Atualizar (min):" + refreshMinutes: "Atualizar (min):", + authentication: "Autenticação:", + username: "Usuário:", + password: "Senha:" }, typeOptions: { remote: "URL Remota", local: "Arquivo Local" }, + authOptions: { + none: "Nenhuma (Público)", + basic: "HTTP Basic Auth" + }, + authWarning: "As credenciais são armazenadas sem criptografia nos dados do plugin. Use senhas específicas do aplicativo quando disponíveis.", placeholders: { calendarName: "Nome do calendário", url: "URL ICS/iCal", filePath: "Caminho do arquivo local (ex: Calendario.ics)", - localFile: "Calendario.ics" + localFile: "Calendario.ics", + username: "Usuário", + password: "Senha" }, statusLabels: { enabled: "Ativado", @@ -2586,6 +2596,7 @@ export const pt: TranslationTree = { notices: { calendarNotFound: 'Calendário "{name}" não encontrado (404). Por favor, verifique se a URL ICS está correta e se o calendário é acessível publicamente.', calendarAccessDenied: 'Acesso ao calendário "{name}" negado (500). Isso pode ser devido a restrições do servidor Microsoft Outlook. Tente regenerar a URL ICS das configurações do seu calendário.', + authenticationFailed: 'Autenticação falhou para o calendário "{name}". Por favor, verifique se seu usuário e senha estão corretos.', fetchRemoteFailed: 'Falha ao buscar calendário remoto "{name}": {error}', readLocalFailed: 'Falha ao ler calendário local "{name}": {error}' } diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 7907abec..d8da8543 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1561,16 +1561,26 @@ export const ru: TranslationTree = { filePath: "Путь к файлу:", color: "Цвет:", refreshMinutes: "Обновление (мин):", + authentication: "Аутентификация:", + username: "Имя пользователя:", + password: "Пароль:", }, typeOptions: { remote: "Удаленный URL", local: "Локальный файл", }, + authOptions: { + none: "Нет (Публичный)", + basic: "HTTP Basic Auth", + }, + authWarning: "Учетные данные хранятся в незашифрованном виде в данных плагина. Используйте пароли приложений, если доступны.", placeholders: { calendarName: "Имя календаря", url: "URL ICS/iCal", filePath: "Путь к локальному файлу (например, Календарь.ics)", localFile: "Календарь.ics", + username: "Имя пользователя", + password: "Пароль", }, statusLabels: { enabled: "Включено", @@ -2578,6 +2588,7 @@ export const ru: TranslationTree = { notices: { calendarNotFound: "Календарь \"{name}\" не найден (404). Пожалуйста, проверьте, что URL ICS правильный и календарь общедоступен.", calendarAccessDenied: "Доступ к календарю \"{name}\" запрещен (500). Это может быть из-за ограничений сервера Microsoft Outlook. Попробуйте перегенерировать URL ICS из настроек календаря.", + authenticationFailed: "Ошибка аутентификации для календаря \"{name}\". Пожалуйста, проверьте правильность имени пользователя и пароля.", fetchRemoteFailed: "Не удалось получить удаленный календарь \"{name}\": {error}", readLocalFailed: "Не удалось прочитать локальный календарь \"{name}\": {error}", }, diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 88b513b9..50a03627 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1561,16 +1561,26 @@ export const zh: TranslationTree = { filePath: "文件路径:", color: "颜色:", refreshMinutes: "刷新(分钟):", + authentication: "认证:", + username: "用户名:", + password: "密码:", }, typeOptions: { remote: "远程URL", local: "本地文件", }, + authOptions: { + none: "无(公开)", + basic: "HTTP Basic Auth", + }, + authWarning: "凭据以明文存储在插件数据中。请尽可能使用应用程序专用密码。", placeholders: { calendarName: "日历名称", url: "ICS/iCal URL", filePath: "本地文件路径(例如,Calendar.ics)", localFile: "Calendar.ics", + username: "用户名", + password: "密码", }, statusLabels: { enabled: "已启用", @@ -2578,6 +2588,7 @@ export const zh: TranslationTree = { notices: { calendarNotFound: "找不到日历\"{name}\"(404)。请检查ICS URL是否正确且日历可公开访问。", calendarAccessDenied: "日历\"{name}\"访问被拒绝(500)。这可能是由于Microsoft Outlook服务器限制。尝试从日历设置重新生成ICS URL。", + authenticationFailed: "日历\"{name}\"认证失败。请检查您的用户名和密码是否正确。", fetchRemoteFailed: "获取远程日历\"{name}\"失败:{error}", readLocalFailed: "读取本地日历\"{name}\"失败:{error}", }, diff --git a/src/services/ICSSubscriptionService.ts b/src/services/ICSSubscriptionService.ts index 7291564f..1dbcb120 100644 --- a/src/services/ICSSubscriptionService.ts +++ b/src/services/ICSSubscriptionService.ts @@ -214,15 +214,24 @@ export class ICSSubscriptionService extends EventEmitter { throw new Error("Remote subscription missing URL"); } + // Build headers with optional Basic Auth + const headers: Record = { + Accept: "text/calendar,*/*;q=0.1", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }; + + // Add HTTP Basic Auth header for Baikal/Davis/CalDAV servers + if (subscription.authType === "basic" && subscription.username && subscription.password) { + const credentials = btoa(`${subscription.username}:${subscription.password}`); + headers["Authorization"] = `Basic ${credentials}`; + } + const response = await requestUrl({ url: subscription.url, method: "GET", - headers: { - Accept: "text/calendar,*/*;q=0.1", - "Accept-Language": "en-US,en;q=0.9", - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - }, + headers, }); icsData = response.text; @@ -262,7 +271,13 @@ export class ICSSubscriptionService extends EventEmitter { // Show user notification for errors with more helpful message if (subscription.type === "remote") { - if (errorMessage.includes("404")) { + if (errorMessage.includes("401") || errorMessage.includes("Unauthorized")) { + new Notice( + this.translate("services.icsSubscription.notices.authenticationFailed", { + name: subscription.name, + }) + ); + } else if (errorMessage.includes("404")) { new Notice( this.translate("services.icsSubscription.notices.calendarNotFound", { name: subscription.name, @@ -298,6 +313,27 @@ export class ICSSubscriptionService extends EventEmitter { private parseICS(icsData: string, subscriptionId: string): ICSEvent[] { try { + // Basic validation - check if this looks like ICS data + const trimmedData = icsData.trim(); + if (!trimmedData.startsWith("BEGIN:VCALENDAR")) { + // Log what we received for debugging + const preview = trimmedData.substring(0, 500); + console.error("ICS parse error: Response does not appear to be ICS data"); + console.error("Response preview:", preview); + + // Check for common CalDAV/WebDAV error responses + if (trimmedData.includes(") => { try { @@ -1288,6 +1310,25 @@ function renderICSSubscriptionsList( updateSubscription({ refreshInterval: minutes }); }); + // Auth type change handler - re-render to show/hide username/password fields + authTypeSelect.addEventListener("change", async () => { + const newAuthType = authTypeSelect.value as "none" | "basic"; + await updateSubscription({ + authType: newAuthType, + // Clear credentials when switching to none + username: newAuthType === "none" ? undefined : subscription.username, + password: newAuthType === "none" ? undefined : subscription.password, + }); + }); + + // Username and password handlers + usernameInput.addEventListener("blur", () => + updateSubscription({ username: (usernameInput as HTMLInputElement).value.trim() }) + ); + passwordInput.addEventListener("blur", () => + updateSubscription({ password: (passwordInput as HTMLInputElement).value }) + ); + // Type change handler - re-render the subscription list to update input type typeSelect.addEventListener("change", async () => { const newType = typeSelect.value as "remote" | "local"; @@ -1411,17 +1452,50 @@ function renderICSSubscriptionsList( // Build content rows const contentRows: { label: string; input: HTMLElement; fullWidth?: boolean }[] = [ - { label: "Enabled:", input: enabledToggle }, - { label: "Name:", input: nameInput }, - { label: "Type:", input: typeSelect }, + { label: translate("settings.integrations.subscriptionsList.labels.enabled"), input: enabledToggle }, + { label: translate("settings.integrations.subscriptionsList.labels.name"), input: nameInput }, + { label: translate("settings.integrations.subscriptionsList.labels.type"), input: typeSelect }, { - label: subscription.type === "remote" ? "URL:" : "File Path:", + label: subscription.type === "remote" + ? translate("settings.integrations.subscriptionsList.labels.url") + : translate("settings.integrations.subscriptionsList.labels.filePath"), input: sourceInput, }, - { label: "Color:", input: colorInput }, - { label: "Refresh (min):", input: refreshInput }, ]; + // Add authentication fields for remote subscriptions + if (subscription.type === "remote") { + contentRows.push({ + label: translate("settings.integrations.subscriptionsList.labels.authentication"), + input: authTypeSelect, + }); + + // Show username/password fields only when Basic auth is selected + if (subscription.authType === "basic") { + contentRows.push({ + label: translate("settings.integrations.subscriptionsList.labels.username"), + input: usernameInput, + }); + contentRows.push({ + label: translate("settings.integrations.subscriptionsList.labels.password"), + input: passwordInput, + }); + + // Add security warning for credential storage + const warningEl = document.createElement("div"); + warningEl.className = "tasknotes-settings__card-warning"; + warningEl.innerHTML = `⚠️${translate("settings.integrations.subscriptionsList.authWarning")}`; + contentRows.push({ + label: "", + input: warningEl, + fullWidth: true, + }); + } + } + + contentRows.push({ label: translate("settings.integrations.subscriptionsList.labels.color"), input: colorInput }); + contentRows.push({ label: translate("settings.integrations.subscriptionsList.labels.refreshMinutes"), input: refreshInput }); + createCard(container, { id: subscription.id, collapsible: true, diff --git a/src/types.ts b/src/types.ts index c88a080d..fba83248 100644 --- a/src/types.ts +++ b/src/types.ts @@ -759,6 +759,10 @@ export interface ICSSubscription { color: string; enabled: boolean; refreshInterval: number; // minutes (for remote) or check interval (for local) + // Authentication for remote calendars (e.g., Baikal, Davis, CalDAV servers) + authType?: "none" | "basic"; // Authentication type + username?: string; // Username for HTTP Basic Auth + password?: string; // Password for HTTP Basic Auth } export interface ICSEvent { diff --git a/styles/settings-view.css b/styles/settings-view.css index c71b3a73..44d50fc5 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -283,6 +283,29 @@ select.tasknotes-settings__card-input { border-color: var(--color-red); } +/* Card Warning */ +.tasknotes-settings__card-warning { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + margin-top: 4px; + background: var(--background-modifier-error); + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); +} + +.tasknotes-settings__card-warning-icon { + flex-shrink: 0; + font-size: 14px; +} + +.tasknotes-settings__card-warning-text { + flex: 1; +} + /* Header Actions */ .tasknotes-settings__card-header-actions { display: flex; diff --git a/tests/unit/services/ICSSubscriptionService.test.ts b/tests/unit/services/ICSSubscriptionService.test.ts index 5ce51857..68ba5ac6 100644 --- a/tests/unit/services/ICSSubscriptionService.test.ts +++ b/tests/unit/services/ICSSubscriptionService.test.ts @@ -294,4 +294,146 @@ END:VCALENDAR`; expect(mondayEvent).toBeUndefined(); }); }); +}); + +describe('ICSSubscriptionService - HTTP Basic Authentication', () => { + let service: ICSSubscriptionService; + let mockPlugin: any; + let mockRequestUrl: jest.Mock; + + beforeEach(() => { + // Get the mocked requestUrl + mockRequestUrl = require('obsidian').requestUrl; + mockRequestUrl.mockReset(); + mockRequestUrl.mockResolvedValue({ + text: `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test Calendar//EN +BEGIN:VEVENT +DTSTART:20250106T150000Z +DTEND:20250106T160000Z +UID:test-event-123 +SUMMARY:Test Event +END:VEVENT +END:VCALENDAR` + }); + + // Mock plugin with i18n + mockPlugin = { + loadData: jest.fn().mockResolvedValue({ icsSubscriptions: [] }), + saveData: jest.fn().mockResolvedValue(undefined), + i18n: { + translate: jest.fn((key: string) => key), + }, + app: { + vault: { + getAbstractFileByPath: jest.fn(), + cachedRead: jest.fn(), + getFiles: jest.fn().mockReturnValue([]), + on: jest.fn().mockReturnValue({ unload: jest.fn() }), + offref: jest.fn() + } + } + }; + + service = new ICSSubscriptionService(mockPlugin); + }); + + it('should not include Authorization header for public calendars (authType none)', async () => { + const subscription = { + id: 'test-sub-1', + name: 'Public Calendar', + url: 'https://example.com/calendar.ics', + type: 'remote' as const, + color: '#4285F4', + enabled: true, + refreshInterval: 60, + authType: 'none' as const, + }; + + // Add subscription and trigger fetch + mockPlugin.loadData.mockResolvedValue({ icsSubscriptions: [subscription] }); + await service.initialize(); + + // Verify requestUrl was called without Authorization header + expect(mockRequestUrl).toHaveBeenCalled(); + const callArgs = mockRequestUrl.mock.calls[0][0]; + expect(callArgs.headers).not.toHaveProperty('Authorization'); + }); + + it('should include Basic Auth header when authType is basic with credentials', async () => { + const subscription = { + id: 'test-sub-2', + name: 'Baikal Calendar', + url: 'https://baikal.example.com/dav.php/calendars/user/default/?export', + type: 'remote' as const, + color: '#4285F4', + enabled: true, + refreshInterval: 60, + authType: 'basic' as const, + username: 'testuser', + password: 'testpass123', + }; + + // Add subscription and trigger fetch + mockPlugin.loadData.mockResolvedValue({ icsSubscriptions: [subscription] }); + await service.initialize(); + + // Verify requestUrl was called with correct Authorization header + expect(mockRequestUrl).toHaveBeenCalled(); + const callArgs = mockRequestUrl.mock.calls[0][0]; + expect(callArgs.headers).toHaveProperty('Authorization'); + + // Verify the Basic auth header format + const expectedAuth = `Basic ${btoa('testuser:testpass123')}`; + expect(callArgs.headers.Authorization).toBe(expectedAuth); + }); + + it('should not include Authorization header when authType is basic but credentials are missing', async () => { + const subscription = { + id: 'test-sub-3', + name: 'Misconfigured Calendar', + url: 'https://example.com/calendar.ics', + type: 'remote' as const, + color: '#4285F4', + enabled: true, + refreshInterval: 60, + authType: 'basic' as const, + username: '', // Empty username + password: 'somepass', + }; + + mockPlugin.loadData.mockResolvedValue({ icsSubscriptions: [subscription] }); + await service.initialize(); + + // Should not include Authorization when username is empty + expect(mockRequestUrl).toHaveBeenCalled(); + const callArgs = mockRequestUrl.mock.calls[0][0]; + expect(callArgs.headers).not.toHaveProperty('Authorization'); + }); + + it('should handle special characters in username and password', async () => { + const subscription = { + id: 'test-sub-4', + name: 'Special Chars Calendar', + url: 'https://example.com/calendar.ics', + type: 'remote' as const, + color: '#4285F4', + enabled: true, + refreshInterval: 60, + authType: 'basic' as const, + username: 'user@example.com', + password: 'p@ss:word/123!', + }; + + mockPlugin.loadData.mockResolvedValue({ icsSubscriptions: [subscription] }); + await service.initialize(); + + expect(mockRequestUrl).toHaveBeenCalled(); + const callArgs = mockRequestUrl.mock.calls[0][0]; + + // Verify special characters are properly encoded + const expectedAuth = `Basic ${btoa('user@example.com:p@ss:word/123!')}`; + expect(callArgs.headers.Authorization).toBe(expectedAuth); + }); }); \ No newline at end of file