diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 57b7b6e..4140132 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -35,7 +35,6 @@ namespace MarcusLaw.OutlookAddin.Core.Services { if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("baseUrl required", nameof(baseUrl)); - if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username required", nameof(username)); if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("apiKey required", nameof(apiKey)); _httpClient = httpClient; @@ -44,9 +43,22 @@ namespace MarcusLaw.OutlookAddin.Core.Services var trimmed = baseUrl.TrimEnd('/') + "/"; _httpClient.BaseAddress = new Uri(trimmed, UriKind.Absolute); - var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + apiKey)); + // EspoCRM auth conventions: + // * API-User type → header "X-Api-Key: " (apiKey is the secret) + // * Regular user+pwd → header "Espo-Authorization: base64(u:p)" + // We default to X-Api-Key since the rollout plan provisions + // dedicated API users. If a username is supplied AND the supplied + // "API key" actually looks like a password (no hex chars / long + // enough), we still keep the username:apiKey Basic fallback so + // either kind of credential pair works. + _httpClient.DefaultRequestHeaders.Remove("X-Api-Key"); _httpClient.DefaultRequestHeaders.Remove("Espo-Authorization"); - _httpClient.DefaultRequestHeaders.Add("Espo-Authorization", token); + _httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey); + if (!string.IsNullOrWhiteSpace(username)) + { + var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + apiKey)); + _httpClient.DefaultRequestHeaders.Add("Espo-Authorization", token); + } _httpClient.DefaultRequestHeaders.Accept.Clear(); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); diff --git a/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs b/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs index a3051fb..563daca 100644 --- a/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs +++ b/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs @@ -75,7 +75,7 @@ namespace MarcusLaw.OutlookAddin.Tests.Services } [Fact] - public async Task FileMailAsync_AddsAuthHeader_AsBase64UsernameColonApiKey() + public async Task FileMailAsync_AddsBothApiKeyAndBasicAuthHeaders() { HttpRequestMessage? captured = null; var (client, _) = BuildClient(req => @@ -86,8 +86,11 @@ namespace MarcusLaw.OutlookAddin.Tests.Services await client.FileMailAsync(new FileEmailRequest { ParentType = "Case", ParentId = "c1" }); - var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes(Username + ":" + ApiKey)); - captured!.Headers.GetValues("Espo-Authorization").Should().ContainSingle().Which.Should().Be(expected); + // Primary auth — API-User type expects X-Api-Key + captured!.Headers.GetValues("X-Api-Key").Should().ContainSingle().Which.Should().Be(ApiKey); + // Fallback for legacy / regular users — Basic in Espo-Authorization + var expectedBasic = Convert.ToBase64String(Encoding.UTF8.GetBytes(Username + ":" + ApiKey)); + captured.Headers.GetValues("Espo-Authorization").Should().ContainSingle().Which.Should().Be(expectedBasic); } [Fact] @@ -218,13 +221,15 @@ namespace MarcusLaw.OutlookAddin.Tests.Services var http = new HttpClient(new Mock().Object); var logger = new LoggerConfiguration().CreateLogger(); - Action a1 = () => new EspoCrmClient(http, BaseUrl, "", ApiKey, logger); + // Username is now optional (API key alone is sufficient when EspoCRM + // user is type "api"); blank apiKey or baseUrl still throw. Action a2 = () => new EspoCrmClient(http, BaseUrl, Username, "", logger); Action a3 = () => new EspoCrmClient(http, "", Username, ApiKey, logger); + Action a4 = () => new EspoCrmClient(http, BaseUrl, "", ApiKey, logger); // empty username — OK - a1.Should().Throw(); a2.Should().Throw(); a3.Should().Throw(); + a4.Should().NotThrow(); } } } diff --git a/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs index b99a9e4..ada7968 100644 --- a/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs @@ -118,9 +118,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } var apiKey = ReadApiKey(); - if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey)) + if (string.IsNullOrWhiteSpace(apiKey)) { - Fail("יש להזין שם משתמש ומפתח API"); + Fail("יש להזין מפתח API"); return; } @@ -128,7 +128,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels try { http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - var probe = new EspoCrmClient(http, baseUrl.ToString(), Username, apiKey, _logger); + var probe = new EspoCrmClient(http, baseUrl.ToString(), Username ?? string.Empty, apiKey, _logger); var user = await probe.TestConnectionAsync().ConfigureAwait(true); Succeed($"התחברות תקינה — משתמש: {user.UserName ?? user.Id}"); } @@ -221,9 +221,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels return; } var apiKey = ReadApiKey(); - if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(apiKey)) + if (string.IsNullOrWhiteSpace(apiKey)) { - Fail("יש להזין שם משתמש ומפתח API"); + Fail("יש להזין מפתח API"); return; } @@ -240,7 +240,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels _settingsManager.Save(_settings); - _credentialStore.Save(Username.Trim(), apiKey); + _credentialStore.Save(string.IsNullOrWhiteSpace(Username) ? "(api-key)" : Username.Trim(), apiKey); _logger.Information("Settings saved (user={User}, url={Url}, watched={WatchedCount})", Username, _settings.EspoCrmUrl, _settings.WatchedFolders.Count);