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); } }