#nullable enable using Bit.Api.Controllers; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; namespace Bit.Api.Test.Controllers; public class SsoCookieVendorControllerTests : IDisposable { private readonly SsoCookieVendorController _sut; private readonly GlobalSettings _globalSettings; public SsoCookieVendorControllerTests() { _globalSettings = new GlobalSettings { Communication = new GlobalSettings.CommunicationSettings { Bootstrap = "ssoCookieVendor", SsoCookieVendor = new GlobalSettings.SsoCookieVendorSettings { CookieName = "test-cookie" } } }; _sut = new SsoCookieVendorController(_globalSettings); } public void Dispose() { _sut?.Dispose(); } private void MockHttpContextWithCookies(Dictionary cookies) { var httpContext = new DefaultHttpContext(); var cookieCollection = Substitute.For(); // Mock the TryGetValue method cookieCollection.TryGetValue(Arg.Any(), out Arg.Any()) .Returns(callInfo => { var key = callInfo.ArgAt(0); if (cookies.TryGetValue(key, out var value)) { callInfo[1] = value; return true; } callInfo[1] = null; return false; }); // Mock the indexer if needed cookieCollection[Arg.Any()].Returns(callInfo => { var key = callInfo.ArgAt(0); return cookies.TryGetValue(key, out var value) ? value : null; }); httpContext.Request.Cookies = cookieCollection; _sut.ControllerContext = new ControllerContext { HttpContext = httpContext }; } [Theory] [InlineData(null)] [InlineData("")] [InlineData("none")] public void Get_WhenBootstrapNotConfigured_Returns404(string? bootstrap) { // Arrange #nullable disable _globalSettings.Communication.Bootstrap = bootstrap; #nullable restore MockHttpContextWithCookies([]); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenCookieNameNotConfigured_Returns500() { // Arrange _globalSettings.Communication.SsoCookieVendor.CookieName = string.Empty; MockHttpContextWithCookies([]); // Act var result = _sut.Get(); // Assert var statusCodeResult = Assert.IsType(result); Assert.Equal(500, statusCodeResult.StatusCode); } [Fact] public void Get_WhenCookieNameIsEmpty_Returns500() { // Arrange _globalSettings.Communication.SsoCookieVendor.CookieName = ""; MockHttpContextWithCookies([]); // Act var result = _sut.Get(); // Assert var statusCodeResult = Assert.IsType(result); Assert.Equal(500, statusCodeResult.StatusCode); } [Fact] public void Get_WhenSingleCookieExists_ReturnsRedirectWithCorrectUri() { // Arrange var cookies = new Dictionary { { "test-cookie", "my-token-value-123" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=my-token-value-123&d=1", redirectResult.Url); } [Fact] public void Get_WhenSingleCookieHasSpecialCharacters_EncodesCorrectly() { // Arrange var cookies = new Dictionary { { "test-cookie", "value with spaces & special=chars!" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); Assert.Contains("value%20with%20spaces", redirectResult.Url); Assert.Contains("%26", redirectResult.Url); // & encoded Assert.Contains("%3D", redirectResult.Url); // = encoded Assert.Contains("%21", redirectResult.Url); // ! encoded } [Fact] public void Get_WhenShardedCookiesExist_ReturnsRedirectWithShardedUri() { // Arrange var cookies = new Dictionary { { "test-cookie-0", "part1" }, { "test-cookie-1", "part2" }, { "test-cookie-2", "part3" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); Assert.StartsWith("bitwarden://sso_cookie_vendor?", redirectResult.Url); Assert.Contains("test-cookie-0=part1", redirectResult.Url); Assert.Contains("test-cookie-1=part2", redirectResult.Url); Assert.Contains("test-cookie-2=part3", redirectResult.Url); Assert.EndsWith("d=1", redirectResult.Url); } [Fact] public void Get_WhenShardedCookiesWithGap_StopsAtFirstGap() { // Arrange var cookies = new Dictionary { { "test-cookie-0", "part0" }, { "test-cookie-1", "part1" }, // Missing test-cookie-2 { "test-cookie-3", "part3" }, { "test-cookie-4", "part4" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); Assert.Contains("test-cookie-0=part0", redirectResult.Url); Assert.Contains("test-cookie-1=part1", redirectResult.Url); Assert.DoesNotContain("test-cookie-3", redirectResult.Url); Assert.DoesNotContain("test-cookie-4", redirectResult.Url); Assert.EndsWith("d=1", redirectResult.Url); } [Fact] public void Get_WhenOnlyGappedShardsExist_Returns404() { // Arrange - only test-cookie-2 exists, not test-cookie-0 or test-cookie-1 var cookies = new Dictionary { { "test-cookie-2", "part2" }, { "test-cookie-3", "part3" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenNoCookiesFound_Returns404() { // Arrange MockHttpContextWithCookies([]); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenUnrelatedCookiesExist_Returns404() { // Arrange var cookies = new Dictionary { { "other-cookie", "value" }, { "another-cookie", "value2" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenUriExceedsMaxLength_Returns400() { // Arrange - create a very long cookie value that will exceed 8192 characters // URI format: "bitwarden://sso_cookie_vendor?test-cookie={value}" // Base URI length is about 43 characters, so we need value > 8149 var longValue = new string('a', 8200); var cookies = new Dictionary { { "test-cookie", longValue } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenSingleCookiePreferredOverSharded_ReturnsSingleCookie() { // Arrange - both single and sharded cookies exist var cookies = new Dictionary { { "test-cookie", "single-value" }, { "test-cookie-0", "shard0" }, { "test-cookie-1", "shard1" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=single-value&d=1", redirectResult.Url); } [Fact] public void Get_WhenEmptyCookieValue_TreatsAsNotFound() { // Arrange var cookies = new Dictionary { { "test-cookie", "" } }; MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert Assert.IsType(result); } [Fact] public void Get_WhenShardedCookiesHaveMaxCount_ProcessesAllShards() { // Arrange - create 20 sharded cookies (MaxShardCount) var cookies = new Dictionary(); for (var i = 0; i < 20; i++) { cookies[$"test-cookie-{i}"] = $"part{i}"; } MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); for (var i = 0; i < 20; i++) { Assert.Contains($"test-cookie-{i}=part{i}", redirectResult.Url); } Assert.EndsWith("d=1", redirectResult.Url); } [Fact] public void Get_WhenShardedCookiesExceedMaxCount_OnlyProcessesFirst20() { // Arrange - create 25 sharded cookies (more than MaxShardCount of 20) var cookies = new Dictionary(); for (var i = 0; i < 25; i++) { cookies[$"test-cookie-{i}"] = $"part{i}"; } MockHttpContextWithCookies(cookies); // Act var result = _sut.Get(); // Assert var redirectResult = Assert.IsType(result); // Should contain first 20 for (var i = 0; i < 20; i++) { Assert.Contains($"test-cookie-{i}=part{i}", redirectResult.Url); } // Should NOT contain 21-25 for (var i = 20; i < 25; i++) { Assert.DoesNotContain($"test-cookie-{i}=part{i}", redirectResult.Url); } } }