diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index d108fdbd4f..8060384502 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -66,7 +66,10 @@ public class HibpController : Controller } else if (response.StatusCode == HttpStatusCode.NotFound) { - return new NotFoundResult(); + /* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches, + an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could + not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */ + return Content("[]", "application/json"); } else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry) { diff --git a/test/Api.Test/Dirt/HibpControllerTests.cs b/test/Api.Test/Dirt/HibpControllerTests.cs new file mode 100644 index 0000000000..9be8d56eae --- /dev/null +++ b/test/Api.Test/Dirt/HibpControllerTests.cs @@ -0,0 +1,292 @@ +using System.Net; +using System.Reflection; +using Bit.Api.Dirt.Controllers; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Api.Test.Dirt; + +[ControllerCustomize(typeof(HibpController))] +[SutProviderCustomize] +public class HibpControllerTests : IDisposable +{ + private readonly HttpClient _originalHttpClient; + private readonly FieldInfo _httpClientField; + + public HibpControllerTests() + { + // Store original HttpClient for restoration + _httpClientField = typeof(HibpController).GetField("_httpClient", BindingFlags.Static | BindingFlags.NonPublic); + _originalHttpClient = (HttpClient)_httpClientField?.GetValue(null); + } + + public void Dispose() + { + // Restore original HttpClient after tests + _httpClientField?.SetValue(null, _originalHttpClient); + } + + [Theory, BitAutoData] + public async Task Get_WithMissingApiKey_ThrowsBadRequestException( + SutProvider sutProvider, + string username) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Equal("HaveIBeenPwned API key not set.", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_WithValidApiKeyAndNoBreaches_Returns200WithEmptyArray( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + var user = new User { Id = userId }; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + // Mock HttpClient to return 404 (no breaches found) + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.NotFound, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + var contentResult = Assert.IsType(result); + Assert.Equal("[]", contentResult.Content); + Assert.Equal("application/json", contentResult.ContentType); + } + + [Theory, BitAutoData] + public async Task Get_WithValidApiKeyAndBreachesFound_Returns200WithBreachData( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var breachData = "[{\"Name\":\"Adobe\",\"Title\":\"Adobe\",\"Domain\":\"adobe.com\"}]"; + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.OK, breachData); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + var contentResult = Assert.IsType(result); + Assert.Equal(breachData, contentResult.Content); + Assert.Equal("application/json", contentResult.ContentType); + } + + [Theory, BitAutoData] + public async Task Get_WithRateLimiting_RetriesWithDelay( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + // First response is rate limited, second is success + var requestCount = 0; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + requestCount++; + if (requestCount == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("retry-after", "1"); + return Task.FromResult(response); + } + else + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + } + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + Assert.Equal(2, requestCount); // Verify retry happened + var contentResult = Assert.IsType(result); + Assert.Equal("[]", contentResult.Content); + } + + [Theory, BitAutoData] + public async Task Get_WithServerError_ThrowsBadRequestException( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.InternalServerError, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Contains("Request failed. Status code:", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_WithBadRequest_ThrowsBadRequestException( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.BadRequest, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Contains("Request failed. Status code:", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_EncodesUsernameCorrectly( + SutProvider sutProvider, + Guid userId) + { + // Arrange + var usernameWithSpecialChars = "test+user@example.com"; + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + string capturedUrl = null; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + capturedUrl = request.RequestUri.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + await sutProvider.Sut.Get(usernameWithSpecialChars); + + // Assert + Assert.NotNull(capturedUrl); + // Username should be URL encoded (+ becomes %2B, @ becomes %40) + Assert.Contains("test%2Buser%40example.com", capturedUrl); + } + + [Theory, BitAutoData] + public async Task SendAsync_IncludesRequiredHeaders( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency().SelfHosted = false; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + HttpRequestMessage capturedRequest = null; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + capturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + await sutProvider.Sut.Get(username); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains("hibp-api-key")); + Assert.True(capturedRequest.Headers.Contains("hibp-client-id")); + Assert.True(capturedRequest.Headers.Contains("User-Agent")); + Assert.Equal("Bitwarden", capturedRequest.Headers.GetValues("User-Agent").First()); + } + + /// + /// Helper to create a mock HttpClient that returns a specific status code and content + /// + private HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string content) + { + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(content) + }); + }); + + return new HttpClient(mockHandler); + } +} + +/// +/// Mock HttpMessageHandler for testing HttpClient behavior +/// +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> _sendAsync; + + public MockHttpMessageHandler(Func> sendAsync) + { + _sendAsync = sendAsync; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _sendAsync(request, cancellationToken); + } +} +