From 7d3c8673eac4ac62f6b54ae573771fdc7ac103fa Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 6 Mar 2026 14:54:42 -0500 Subject: [PATCH] PM-32487 - Emergency Access - invite or update - require min value of 1 for wait time in days. --- .../Request/EmergencyAccessRequestModels.cs | 2 + .../EmergencyAccessControllerTests.cs | 292 ++++++++++++++++++ .../EmergencyAccessRequestModelsTests.cs | 150 +++++++++ 3 files changed, 444 insertions(+) create mode 100644 test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs create mode 100644 test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 75e96ebc66b0..71e90f102acf 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -17,6 +17,7 @@ public class EmergencyAccessInviteRequestModel [Required] public EmergencyAccessType? Type { get; set; } [Required] + [Range(1, short.MaxValue)] public int WaitTimeDays { get; set; } } @@ -25,6 +26,7 @@ public class EmergencyAccessUpdateRequestModel [Required] public EmergencyAccessType Type { get; set; } [Required] + [Range(1, short.MaxValue)] public int WaitTimeDays { get; set; } public string KeyEncrypted { get; set; } diff --git a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs new file mode 100644 index 000000000000..e5cacb3f163c --- /dev/null +++ b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs @@ -0,0 +1,292 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.Auth.Controllers; +using Bit.Api.Auth.Models.Response; +using Bit.Api.Models.Response; +using Bit.Api.Vault.Models.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Controllers; + +[ControllerCustomize(typeof(EmergencyAccessController))] +[SutProviderCustomize] +public class EmergencyAccessControllerTests +{ + [Theory, BitAutoData] + public async Task GetContacts_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + List details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyDetailsByGrantorIdAsync(user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.GetContacts(); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(details.Count, result.Data.Count()); + } + + [Theory, BitAutoData] + public async Task GetGrantees_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + List details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyDetailsByGranteeIdAsync(user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.GetGrantees(); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(details.Count, result.Data.Count()); + } + + [Theory, BitAutoData] + public async Task Get_ReturnsGranteeDetailsResponseModel( + SutProvider sutProvider, + User user, + EmergencyAccessDetails details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAsync(details.Id, user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.Get(details.Id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Policies_ReturnsListResponseModel( + SutProvider sutProvider, + User user, + List policies, + Guid id) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetPoliciesAsync(id, user) + .Returns(policies); + + // Act + var result = await sutProvider.Sut.Policies(id); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task Policies_WhenGrantorIsNotOrgOwner_ReturnsNullDataAsync( + SutProvider sutProvider, + User user, + Guid id) + { + // Arrange + // GetPoliciesAsync returns null when the grantor is not an org owner + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetPoliciesAsync(id, user) + .Returns((ICollection)null); + + // Act + var result = await sutProvider.Sut.Policies(id); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Null(result.Data); + } + + [Theory, BitAutoData] + public async Task Put_WithNullEmergencyAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid id, + Bit.Api.Auth.Models.Request.EmergencyAccessUpdateRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(id) + .Returns((EmergencyAccess)null); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.Put(id, model)); + } + + [Theory, BitAutoData] + public async Task Put_WithValidEmergencyAccess_CallsSaveAsync( + SutProvider sutProvider, + User user, + EmergencyAccess emergencyAccess, + Bit.Api.Auth.Models.Request.EmergencyAccessUpdateRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.Id) + .Returns(emergencyAccess); + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + // Act + await sutProvider.Sut.Put(emergencyAccess.Id, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Any(), user); + } + + [Theory, BitAutoData] + public async Task Invite_CallsInviteAsync( + SutProvider sutProvider, + User user, + Bit.Api.Auth.Models.Request.EmergencyAccessInviteRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + // Act + await sutProvider.Sut.Invite(model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .InviteAsync(user, model.Email, model.Type!.Value, model.WaitTimeDays); + } + + [Theory, BitAutoData] + public async Task Takeover_ReturnsTakeoverResponseModel( + SutProvider sutProvider, + User granteeUser, + User grantorUser, + EmergencyAccess emergencyAccess, + Guid id) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(granteeUser); + + sutProvider.GetDependency() + .TakeoverAsync(id, granteeUser) + .Returns((emergencyAccess, grantorUser)); + + // Act + var result = await sutProvider.Sut.Takeover(id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ViewCiphers_ReturnsViewResponseModel( + SutProvider sutProvider, + User user, + EmergencyAccessViewData viewData, + Guid id) + { + // Arrange + viewData.Ciphers = []; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .ViewAsync(id, user) + .Returns(viewData); + + // Act + var result = await sutProvider.Sut.ViewCiphers(id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetAttachmentData_ReturnsAttachmentResponseModel( + SutProvider sutProvider, + User user, + Guid id, + Guid cipherId, + string attachmentId) + { + // Arrange + // CipherAttachment.MetaData has a circular self-reference, so construct manually + var attachmentData = new AttachmentResponseData + { + Id = attachmentId, + Url = "https://example.com/attachment", + Cipher = new Cipher(), + Data = new CipherAttachment.MetaData { FileName = "file.txt", Key = "key", Size = 1024 }, + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetAttachmentDownloadAsync(id, cipherId, attachmentId, user) + .Returns(attachmentData); + + // Act + var result = await sutProvider.Sut.GetAttachmentData(id, cipherId, attachmentId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } +} diff --git a/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs b/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs new file mode 100644 index 000000000000..e57bec545567 --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs @@ -0,0 +1,150 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request; + +public class EmergencyAccessInviteRequestModelTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Validate_WaitTimeDays_BelowMinimum_Invalid(int waitTimeDays) + { + var model = new EmergencyAccessInviteRequestModel + { + Email = "test@example.com", + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.Contains(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(90)] + [InlineData(short.MaxValue)] + public void Validate_WaitTimeDays_ValidRange_Valid(int waitTimeDays) + { + var model = new EmergencyAccessInviteRequestModel + { + Email = "test@example.com", + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.DoesNotContain(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + private static List Validate(EmergencyAccessInviteRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} + +public class EmergencyAccessUpdateRequestModelTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Validate_WaitTimeDays_BelowMinimum_Invalid(int waitTimeDays) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.Contains(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(90)] + [InlineData(short.MaxValue)] + public void Validate_WaitTimeDays_ValidRange_Valid(int waitTimeDays) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.DoesNotContain(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Fact] + public void ToEmergencyAccess_BothKeysPresent_UpdatesKey() + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 7, + KeyEncrypted = "new-encrypted-key", + }; + var existing = new EmergencyAccess { KeyEncrypted = "old-encrypted-key" }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal("new-encrypted-key", result.KeyEncrypted); + } + + [Theory] + [InlineData(null, "new-encrypted-key")] + [InlineData("", "new-encrypted-key")] + [InlineData(" ", "new-encrypted-key")] + [InlineData("old-encrypted-key", null)] + [InlineData("old-encrypted-key", "")] + [InlineData("old-encrypted-key", " ")] + public void ToEmergencyAccess_EitherKeyMissingOrWhitespace_DoesNotUpdateKey( + string? existingKey, string? newKey) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 7, + KeyEncrypted = newKey, + }; + var existing = new EmergencyAccess { KeyEncrypted = existingKey }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal(existingKey, result.KeyEncrypted); + } + + [Fact] + public void ToEmergencyAccess_AlwaysUpdatesTypeAndWaitTimeDays() + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 14, + }; + var existing = new EmergencyAccess + { + Type = EmergencyAccessType.View, + WaitTimeDays = 7, + }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal(EmergencyAccessType.Takeover, result.Type); + Assert.Equal(14, result.WaitTimeDays); + } + + private static List Validate(EmergencyAccessUpdateRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +}