From 0a1bccba53ac46f1d12b1e5a1211ff2e3e7223e9 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 16 Jul 2026 12:17:56 -0400 Subject: [PATCH 1/3] [PM-34429] Create an endpoint to retrieve the InviteBlob --- .../OrganizationUsersController.cs | 25 ++++++++++- .../GetOrganizationInviteBlobRequestModel.cs | 12 +++++ .../OrganizationInviteBlobResponseModel.cs | 3 ++ .../GetOrganizationInviteBlobCommand.cs | 45 +++++++++++++++++++ .../GetOrganizationInviteBlobRequest.cs | 26 +++++++++++ .../IGetOrganizationInviteBlobCommand.cs | 12 +++++ ...OrganizationServiceCollectionExtensions.cs | 1 + 7 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/GetOrganizationInviteBlobRequestModel.cs create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteBlobResponseModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IGetOrganizationInviteBlobCommand.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index d30b2c729f96..5c27e6bfeac1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -91,6 +91,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IUpdateUserResetPasswordEnrollmentCommand _updateUserResetPasswordEnrollmentCommand; private readonly IAcceptOrganizationInviteLinkCommand _acceptOrganizationInviteLinkCommand; private readonly IConfirmOrganizationInviteLinkCommand _confirmOrganizationInviteLinkCommand; + private readonly IGetOrganizationInviteBlobCommand _getOrganizationInviteBlobCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -126,7 +127,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand, IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery, IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand, - IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand) + IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand, + IGetOrganizationInviteBlobCommand getOrganizationInviteBlobCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -163,6 +165,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor _updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand; _acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand; _confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand; + _getOrganizationInviteBlobCommand = getOrganizationInviteBlobCommand; } [HttpGet("{id}")] @@ -896,4 +899,24 @@ public async Task ConfirmInviteLink([FromBody] ConfirmOrganizationInvit return Handle(result, _ => TypedResults.Ok()); } + + [HttpPost("/organizations/users/invite-link/invite-blob")] + [RequireFeature(FeatureFlagKeys.GenerateInviteLink)] + public async Task GetInviteBlob([FromBody] GetOrganizationInviteBlobRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _getOrganizationInviteBlobCommand.GetInviteBlobAsync(new GetOrganizationInviteBlobRequest + { + OrganizationId = model.OrganizationId, + Code = model.Code, + User = user, + }); + + return Handle(result, inviteBlob => TypedResults.Ok(new OrganizationInviteBlobResponseModel(inviteBlob))); + } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/GetOrganizationInviteBlobRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/GetOrganizationInviteBlobRequestModel.cs new file mode 100644 index 000000000000..66a7946f1738 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/GetOrganizationInviteBlobRequestModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class GetOrganizationInviteBlobRequestModel +{ + [Required] + public required Guid Code { get; set; } + + [Required] + public required Guid OrganizationId { get; set; } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteBlobResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteBlobResponseModel.cs new file mode 100644 index 000000000000..027fc74ac504 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteBlobResponseModel.cs @@ -0,0 +1,3 @@ +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public record OrganizationInviteBlobResponseModel(string InviteBlob); diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs new file mode 100644 index 000000000000..ce378dc9a979 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs @@ -0,0 +1,45 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +/// +/// Retrieves the opaque invite blob for an invite link. See +/// +public class GetOrganizationInviteBlobCommand( + IOrganizationInviteLinkRepository organizationInviteLinkRepository, + IOrganizationAbilityCacheService organizationAbilityCacheService) + : IGetOrganizationInviteBlobCommand +{ + public async Task> GetInviteBlobAsync(GetOrganizationInviteBlobRequest request) + { + var user = request.User; + + var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId); + if (link is null || !link.CodeMatches(request.Code.ToString())) + { + return new InviteLinkNotFound(); + } + + var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(link.OrganizationId); + if (organizationAbility is null or { Enabled: false }) + { + return new InviteLinkNotFound(); + } + + if (!organizationAbility.UseInviteLinks) + { + return new InviteLinkNotAvailable(); + } + + if (!InviteLinkDomainValidator.IsEmailDomainAllowed(user.Email, link.GetAllowedDomains())) + { + return new EmailDomainNotAllowed(); + } + + return link.Invite; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobRequest.cs new file mode 100644 index 000000000000..110d815110f6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobRequest.cs @@ -0,0 +1,26 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +/// +/// The data required to retrieve the invite blob for an invite link. The blob is an opaque +/// cryptographic value that the server stores and transports but never inspects; it is decrypted +/// client-side to reconstruct and confirm the invite link. +/// +public record GetOrganizationInviteBlobRequest +{ + /// + /// The ID of the organization whose invite link the user is retrieving the blob for. + /// + public required Guid OrganizationId { get; init; } + + /// + /// The secret code embedded in the invite link the user is retrieving the blob for. + /// + public required Guid Code { get; init; } + + /// + /// The authenticated user requesting the invite blob. + /// + public required User User { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IGetOrganizationInviteBlobCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IGetOrganizationInviteBlobCommand.cs new file mode 100644 index 000000000000..04569c7c61f8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IGetOrganizationInviteBlobCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; + +/// +/// Retrieves the opaque invite blob for an invite link after validating that the link exists, its +/// organization is enabled and supports invite links, and the user's email domain is allowed. +/// +public interface IGetOrganizationInviteBlobCommand +{ + Task> GetInviteBlobAsync(GetOrganizationInviteBlobRequest request); +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index a6231fabb12e..4bf454dfa476 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -212,6 +212,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); } private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) From 55c449680b1d79c8c55757a16f84dccf9c29704e Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 16 Jul 2026 12:37:00 -0400 Subject: [PATCH 2/3] [PM-34429] Add tests --- ...zationUsersControllerGetInviteBlobTests.cs | 158 +++++++++++++ .../GetOrganizationInviteBlobCommandTests.cs | 222 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerGetInviteBlobTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommandTests.cs diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerGetInviteBlobTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerGetInviteBlobTests.cs new file mode 100644 index 000000000000..cb6940e02737 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerGetInviteBlobTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUsersControllerGetInviteBlobTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private const string _validEncryptedKey = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationUsersControllerGetInviteBlobTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.GenerateInviteLink) + .Returns(true); + featureService + .IsEnabled(FeatureFlagKeys.InviteLinkAutoConfirm) + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + var organizationRepository = _factory.GetService(); + _organization.UseInviteLinks = true; + await organizationRepository.ReplaceAsync(_organization); + + // The endpoint reads Enabled/UseInviteLinks from the organization ability cache, so refresh it + // to reflect the invite links being enabled above. + await _factory.GetService() + .UpsertOrganizationAbilityAsync(_organization); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task GetInviteBlob_WithValidRequest_ReturnsOkAndInviteBlob() + { + // Arrange + var code = await CreateInviteLinkAsync(["example.com"]); + var (joinerClient, _) = await CreateJoinerClientAsync(); + + // Act + var response = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, code)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(_validEncryptedKey, result.InviteBlob); + } + + [Fact] + public async Task GetInviteBlob_WithUnknownCode_ReturnsNotFound() + { + // Arrange + await CreateInviteLinkAsync(["example.com"]); + var (joinerClient, _) = await CreateJoinerClientAsync(); + + // Act — supply a code that does not match the organization's invite link + var response = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, Guid.NewGuid())); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetInviteBlob_WhenEmailDomainNotAllowed_ReturnsBadRequest() + { + // Arrange — the link only allows a domain the joiner does not have + var code = await CreateInviteLinkAsync(["notallowed.example"]); + var (joinerClient, _) = await CreateJoinerClientAsync(); + + // Act + var response = await joinerClient.PostAsJsonAsync( + "/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, code)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private async Task CreateInviteLinkAsync(string[] allowedDomains) + { + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = allowedDomains, + Invite = _validEncryptedKey, + SupportsConfirmation = true, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + return created.Code; + } + + private async Task<(HttpClient Client, string Email)> CreateJoinerClientAsync() + { + var joinerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(joinerEmail); + var joinerClient = _factory.CreateClient(); + var joinerLoginHelper = new LoginHelper(_factory, joinerClient); + await joinerLoginHelper.LoginAsync(joinerEmail); + return (joinerClient, joinerEmail); + } + + private static GetOrganizationInviteBlobRequestModel BuildRequest(Guid organizationId, Guid code) => + new() + { + OrganizationId = organizationId, + Code = code, + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommandTests.cs new file mode 100644 index 000000000000..989b94924c5d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommandTests.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; + +[SutProviderCustomize] +public class GetOrganizationInviteBlobCommandTests +{ + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WithLinkNotFound_ReturnsInviteLinkNotFound( + GetOrganizationInviteBlobRequest request, + SutProvider sutProvider) + { + // Act + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WithCodeMismatch_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + + // Act — pass a different code than the one stored on the link + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.NewGuid(), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WithOrganizationAbilityNotFound_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(inviteLink.OrganizationId) + .Returns((OrganizationAbility?)null); + + // Act + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WithOrganizationDisabled_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + OrganizationAbility organizationAbility, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + organizationAbility.Enabled = false; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(inviteLink.OrganizationId) + .Returns(organizationAbility); + + // Act + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WhenOrganizationDoesNotUseInviteLinks_ReturnsInviteLinkNotAvailable( + OrganizationInviteLink inviteLink, + OrganizationAbility organizationAbility, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + organizationAbility.Enabled = true; + organizationAbility.UseInviteLinks = false; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(inviteLink.OrganizationId) + .Returns(organizationAbility); + + // Act + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WhenEmailDomainNotAllowed_ReturnsEmailDomainNotAllowed( + OrganizationInviteLink inviteLink, + OrganizationAbility organizationAbility, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + inviteLink.AllowedDomains = JsonSerializer.Serialize(new[] { "allowed.example" }); + organizationAbility.Enabled = true; + organizationAbility.UseInviteLinks = true; + user.Email = "user@notallowed.example"; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(inviteLink.OrganizationId) + .Returns(organizationAbility); + + // Act + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task GetInviteBlobAsync_WithValidRequest_ReturnsInviteBlob( + OrganizationInviteLink inviteLink, + OrganizationAbility organizationAbility, + User user, + SutProvider sutProvider) + { + // Arrange + inviteLink.Code = Guid.NewGuid().ToString(); + inviteLink.AllowedDomains = JsonSerializer.Serialize(new[] { "allowed.example" }); + organizationAbility.Enabled = true; + organizationAbility.UseInviteLinks = true; + user.Email = "user@allowed.example"; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId) + .Returns(inviteLink); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(inviteLink.OrganizationId) + .Returns(organizationAbility); + + // Act + var request = new GetOrganizationInviteBlobRequest + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + User = user, + }; + var result = await sutProvider.Sut.GetInviteBlobAsync(request); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(inviteLink.Invite, result.AsSuccess); + } +} From 7ef5e26f69f7ef5729c762fb084a8e9029451c9d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 16 Jul 2026 13:10:29 -0400 Subject: [PATCH 3/3] [PM-34429] wip --- .../InviteLinks/GetOrganizationInviteBlobCommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs index ce378dc9a979..cb3f12c2ebd3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/GetOrganizationInviteBlobCommand.cs @@ -8,6 +8,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; /// /// Retrieves the opaque invite blob for an invite link. See +/// for the behavior. /// public class GetOrganizationInviteBlobCommand( IOrganizationInviteLinkRepository organizationInviteLinkRepository,