diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 590895665d5b..100cd7caf682 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -70,6 +69,7 @@ public class OrganizationsController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; + private readonly IOrganizationUpdateCommand _organizationUpdateCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -94,7 +94,8 @@ public OrganizationsController( IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient, - IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand, + IOrganizationUpdateCommand organizationUpdateCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -119,6 +120,7 @@ public OrganizationsController( _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; _organizationUpdateKeysCommand = organizationUpdateKeysCommand; + _organizationUpdateCommand = organizationUpdateCommand; } [HttpGet("{id}")] @@ -224,36 +226,31 @@ public async Task CreateWithoutPaymentAsync([FromBody return new OrganizationResponseModel(result.Organization, plan); } - [HttpPut("{id}")] - public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) + [HttpPut("{organizationId:guid}")] + public async Task Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model) { - var orgIdGuid = new Guid(id); + // If billing email is being changed, require subscription editing permissions. + // Otherwise, organization owner permissions are sufficient. + var requiresBillingPermission = model.BillingEmail is not null; + var authorized = requiresBillingPermission + ? await _currentContext.EditSubscription(organizationId) + : await _currentContext.OrganizationOwner(organizationId); - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) + if (!authorized) { - throw new NotFoundException(); + return TypedResults.Unauthorized(); } - var updateBilling = ShouldUpdateBilling(model, organization); - - var hasRequiredPermissions = updateBilling - ? await _currentContext.EditSubscription(orgIdGuid) - : await _currentContext.OrganizationOwner(orgIdGuid); - - if (!hasRequiredPermissions) - { - throw new NotFoundException(); - } + var commandRequest = model.ToCommandRequest(organizationId); + var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest); - await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); - var plan = await _pricingClient.GetPlan(organization.PlanType); - return new OrganizationResponseModel(organization, plan); + var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType); + return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan)); } [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] - public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + public async Task PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model) { return await Put(id, model); } @@ -588,11 +585,4 @@ public async Task GetPlanType(string id) return organization.PlanType; } - - private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization) - { - var organizationNameChanged = model.Name != organization.Name; - var billingEmailChanged = model.BillingEmail != organization.BillingEmail; - return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged); - } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index 5a3192c121a1..6c3867fe0990 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,41 +1,28 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Settings; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationUpdateRequestModel { - [Required] [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } - [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string BusinessName { get; set; } + public string? Name { get; set; } + [EmailAddress] - [Required] [StringLength(256)] - public string BillingEmail { get; set; } - public Permissions Permissions { get; set; } - public OrganizationKeysRequestModel Keys { get; set; } + public string? BillingEmail { get; set; } + + public OrganizationKeysRequestModel? Keys { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings) + public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new() { - if (!globalSettings.SelfHosted) - { - // These items come from the license file - existingOrganization.Name = Name; - existingOrganization.BusinessName = BusinessName; - existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - } - Keys?.ToOrganization(existingOrganization); - return existingOrganization; - } + OrganizationId = organizationId, + Name = Name, + BillingEmail = BillingEmail, + PublicKey = Keys?.PublicKey, + EncryptedPrivateKey = Keys?.EncryptedPrivateKey + }; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs new file mode 100644 index 000000000000..85fbcd2740a8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationUpdateCommand +{ + /// + /// Updates an organization's information in the Bitwarden database and Stripe (if required). + /// Also optionally updates an organization's public-private keypair if it was not created with one. + /// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file. + /// + /// The update request containing the details to be updated. + Task UpdateAsync(OrganizationUpdateRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs new file mode 100644 index 000000000000..64358f3048e3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public class OrganizationUpdateCommand( + IOrganizationService organizationService, + IOrganizationRepository organizationRepository, + IGlobalSettings globalSettings, + IOrganizationBillingService organizationBillingService +) : IOrganizationUpdateCommand +{ + public async Task UpdateAsync(OrganizationUpdateRequest request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (globalSettings.SelfHosted) + { + return await UpdateSelfHostedAsync(organization, request); + } + + return await UpdateCloudAsync(organization, request); + } + + private async Task UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request) + { + // Store original values for comparison + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + // Apply updates to organization + organization.UpdateDetails(request); + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + + // Update billing information in Stripe if required + await UpdateBillingAsync(organization, originalName, originalBillingEmail); + + return organization; + } + + /// + /// Self-host cannot update the organization details because they are set by the license file. + /// However, this command does offer a soft migration pathway for organizations without public and private keys. + /// If we remove this migration code in the future, this command and endpoint can become cloud only. + /// + private async Task UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request) + { + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + return organization; + } + + private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail) + { + // Update Stripe if name or billing email changed + var shouldUpdateBilling = originalName != organization.Name || + originalBillingEmail != organization.BillingEmail; + + if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + return; + } + + await organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs new file mode 100644 index 000000000000..e90c39bc54cf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public static class OrganizationUpdateExtensions +{ + /// + /// Updates the organization name and/or billing email. + /// Any null property on the request object will be skipped. + /// + public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request) + { + // These values may or may not be sent by the client depending on the operation being performed. + // Skip any values not provided. + if (request.Name is not null) + { + organization.Name = request.Name; + } + + if (request.BillingEmail is not null) + { + organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim(); + } + } + + /// + /// Updates the organization public and private keys if provided and not already set. + /// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft + /// migration that will silently migrate organizations when they change their details. + /// + public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request) + { + if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey)) + { + organization.PublicKey = request.PublicKey; + } + + if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey)) + { + organization.PrivateKey = request.EncryptedPrivateKey; + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs new file mode 100644 index 000000000000..21d49486781c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +/// +/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code). +/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything. +/// +public record OrganizationUpdateRequest +{ + /// + /// The ID of the organization to update. + /// + public required Guid OrganizationId { get; init; } + + /// + /// The new organization name to apply (optional, this is skipped if not provided). + /// + public string? Name { get; init; } + + /// + /// The new billing email address to apply (optional, this is skipped if not provided). + /// + public string? BillingEmail { get; init; } + + /// + /// The organization's public key to set (optional, only set if not already present on the organization). + /// + public string? PublicKey { get; init; } + + /// + /// The organization's encrypted private key to set (optional, only set if not already present on the organization). + /// + public string? EncryptedPrivateKey { get; init; } +} diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index d34bd86e7bf5..6c7f087ffaa2 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -56,4 +56,15 @@ Task UpdatePaymentMethod( /// Thrown when the is . /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); + + /// + /// Updates the organization name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// + /// The caller should ensure that the organization has a GatewayCustomerId before calling this method. + /// + /// The organization to update in Stripe. + /// Thrown when the organization does not have a GatewayCustomerId. + Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index b10f04d76606..65c339fad46e 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -176,6 +176,35 @@ public async Task UpdateSubscriptionPlanFrequency( } } + public async Task UpdateOrganizationNameAndEmail(Organization organization) + { + if (organization.GatewayCustomerId is null) + { + throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId."); + } + + var newDisplayName = organization.DisplayName(); + + await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = organization.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + // This overwrites the existing custom fields for this organization + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = newDisplayName.Length <= 30 + ? newDisplayName + : newDisplayName[..30] + }] + }, + }); + } + #region Utilities private async Task CreateCustomerAsync( diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 91504b0b9bef..91030c51513a 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; @@ -87,6 +88,7 @@ private static void AddOrganizationDeleteCommands(this IServiceCollection servic private static void AddOrganizationUpdateCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationEnableCommands(this IServiceCollection services) => diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 000000000000..c234e77bc8d8 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationsControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + private readonly string _billingEmail = "billing@example.com"; + private readonly string _organizationName = "Organizations Controller Test Org"; + + public OrganizationsControllerTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"org-integration-test-{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + name: _organizationName, + billingEmail: _billingEmail, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 5, + paymentMethod: PaymentMethodType.Card); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Put_AsOwner_WithoutProvider_CanUpdateOrganization() + { + // Arrange - Regular organization owner (no provider) + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsProvider_CanUpdateOrganization() + { + // Create and login as a new account to be the provider user (not the owner) + var providerUserEmail = $"provider-{Guid.NewGuid()}@example.com"; + var (token, _) = await _factory.LoginWithNewAccount(providerUserEmail); + + // Set up provider linked to org and ProviderUser entry + var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, + ProviderType.Msp); + await ProviderTestHelpers.CreateProviderUserAsync(_factory, provider.Id, providerUserEmail, + ProviderUserType.ProviderAdmin); + + await _loginHelper.LoginAsync(providerUserEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_NotMemberOrProvider_CannotUpdateOrganization() + { + // Create and login as a new account to be unrelated to the org + var userEmail = "stranger@example.com"; + await _factory.LoginWithNewAccount(userEmail); + await _loginHelper.LoginAsync(userEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + // Verify the organization name was not updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal(_organizationName, updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsOwner_WithProvider_CanRenameOrganization() + { + // Arrange - Create provider and link to organization + // The active user is ONLY an org owner, NOT a provider user + await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp); + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = null + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was actually updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsOwner_WithProvider_CannotChangeBillingEmail() + { + // Arrange - Create provider and link to organization + // The active user is ONLY an org owner, NOT a provider user + await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp); + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "updatedbilling@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + // Verify the organization was not updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal(_organizationName, updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } +} diff --git a/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs b/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs new file mode 100644 index 000000000000..ab52bcd076fb --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs @@ -0,0 +1,77 @@ +using Bit.Api.IntegrationTest.Factories; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; + +namespace Bit.Api.IntegrationTest.Helpers; + +public static class ProviderTestHelpers +{ + /// + /// Creates a provider and links it to an organization. + /// This does NOT create any provider users. + /// + /// The API application factory + /// The organization ID to link to the provider + /// The type of provider to create + /// The provider status (defaults to Created) + /// The created provider + public static async Task CreateProviderAndLinkToOrganizationAsync( + ApiApplicationFactory factory, + Guid organizationId, + ProviderType providerType, + ProviderStatusType providerStatus = ProviderStatusType.Created) + { + var providerRepository = factory.GetService(); + var providerOrganizationRepository = factory.GetService(); + + // Create the provider + var provider = await providerRepository.CreateAsync(new Provider + { + Name = $"Test {providerType} Provider", + BusinessName = $"Test {providerType} Provider Business", + BillingEmail = $"provider-{providerType.ToString().ToLower()}@example.com", + Type = providerType, + Status = providerStatus, + Enabled = true + }); + + // Link the provider to the organization + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organizationId, + Key = "test-provider-key" + }); + + return provider; + } + + /// + /// Creates a providerUser for a provider. + /// + public static async Task CreateProviderUserAsync( + ApiApplicationFactory factory, + Guid providerId, + string userEmail, + ProviderUserType providerUserType) + { + var userRepository = factory.GetService(); + var user = await userRepository.GetByEmailAsync(userEmail); + if (user is null) + { + throw new Exception("No user found in test setup."); + } + + var providerUserRepository = factory.GetService(); + return await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = providerId, + Status = ProviderUserStatusType.Confirmed, + UserId = user.Id, + Key = Guid.NewGuid().ToString(), + Type = providerUserType + }); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index f999dd520ee5..d87f035a13d9 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; @@ -8,9 +7,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.Models.Business.Tokenables; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -20,7 +16,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; @@ -31,101 +26,23 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; -using Bit.Core.Tokens; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Api.Test.AdminConsole.Controllers; -public class OrganizationsControllerTests : IDisposable +[ControllerCustomize(typeof(OrganizationsController))] +[SutProviderCustomize] +public class OrganizationsControllerTests { - private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPolicyRepository _policyRepository; - private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly ISsoConfigService _ssoConfigService; - private readonly IUserService _userService; - private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; - private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; - private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; - private readonly IFeatureService _featureService; - private readonly IProviderRepository _providerRepository; - private readonly IProviderBillingService _providerBillingService; - private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; - private readonly IOrganizationDeleteCommand _organizationDeleteCommand; - private readonly IPolicyRequirementQuery _policyRequirementQuery; - private readonly IPricingClient _pricingClient; - private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; - private readonly OrganizationsController _sut; - - public OrganizationsControllerTests() - { - _currentContext = Substitute.For(); - _globalSettings = Substitute.For(); - _organizationRepository = Substitute.For(); - _organizationService = Substitute.For(); - _organizationUserRepository = Substitute.For(); - _policyRepository = Substitute.For(); - _ssoConfigRepository = Substitute.For(); - _ssoConfigService = Substitute.For(); - _getOrganizationApiKeyQuery = Substitute.For(); - _rotateOrganizationApiKeyCommand = Substitute.For(); - _organizationApiKeyRepository = Substitute.For(); - _userService = Substitute.For(); - _createOrganizationApiKeyCommand = Substitute.For(); - _featureService = Substitute.For(); - _providerRepository = Substitute.For(); - _providerBillingService = Substitute.For(); - _orgDeleteTokenDataFactory = Substitute.For>(); - _removeOrganizationUserCommand = Substitute.For(); - _cloudOrganizationSignUpCommand = Substitute.For(); - _organizationDeleteCommand = Substitute.For(); - _policyRequirementQuery = Substitute.For(); - _pricingClient = Substitute.For(); - _organizationUpdateKeysCommand = Substitute.For(); - - _sut = new OrganizationsController( - _organizationRepository, - _organizationUserRepository, - _policyRepository, - _organizationService, - _userService, - _currentContext, - _ssoConfigRepository, - _ssoConfigService, - _getOrganizationApiKeyQuery, - _rotateOrganizationApiKeyCommand, - _createOrganizationApiKeyCommand, - _organizationApiKeyRepository, - _featureService, - _globalSettings, - _providerRepository, - _providerBillingService, - _orgDeleteTokenDataFactory, - _removeOrganizationUserCommand, - _cloudOrganizationSignUpCommand, - _organizationDeleteCommand, - _policyRequirementQuery, - _pricingClient, - _organizationUpdateKeysCommand); - } - - public void Dispose() - { - _sut?.Dispose(); - } - - [Theory, AutoData] + [Theory, BitAutoData] public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvidesKeyConnector( - Guid orgId, User user) + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -140,21 +57,24 @@ public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvide user.UsesKeyConnector = true; - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); - var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Leave(orgId)); Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.", exception.Message); - await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser( - Guid orgId, User user) + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -166,27 +86,34 @@ public async Task OrganizationsController_UserCannotLeaveOrganizationThatManages Enabled = true, OrganizationId = orgId, }; - var foundOrg = new Organization(); - foundOrg.Id = orgId; + var foundOrg = new Organization + { + Id = orgId + }; + + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { foundOrg }); - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { { foundOrg } }); - var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Leave(orgId)); Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.", exception.Message); - await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); } [Theory] - [InlineAutoData(true, false)] - [InlineAutoData(false, true)] - [InlineAutoData(false, false)] + [BitAutoData(true, false)] + [BitAutoData(false, true)] + [BitAutoData(false, false)] public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector( - bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user) + bool keyConnectorEnabled, + bool userUsesKeyConnector, + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -203,18 +130,19 @@ public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProv user.UsesKeyConnector = userUsesKeyConnector; - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); - await _sut.Leave(orgId); + await sutProvider.Sut.Leave(orgId); - await _removeOrganizationUserCommand.Received(1).UserLeaveAsync(orgId, user.Id); + await sutProvider.GetDependency().Received(1).UserLeaveAsync(orgId, user.Id); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats( + SutProvider sutProvider, Provider provider, Organization organization, User user, @@ -228,87 +156,89 @@ public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProviders provider.Type = ProviderType.Msp; provider.Status = ProviderStatusType.Billable; - _currentContext.OrganizationOwner(organizationId).Returns(true); + sutProvider.GetDependency().OrganizationOwner(organizationId).Returns(true); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().VerifySecretAsync(user, requestModel.Secret).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + await sutProvider.Sut.Delete(organizationId.ToString(), requestModel); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - - _userService.VerifySecretAsync(user, requestModel.Secret).Returns(true); - - _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); - - await _sut.Delete(organizationId.ToString(), requestModel); - - await _providerBillingService.Received(1) + await sutProvider.GetDependency().Received(1) .ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); - await _organizationDeleteCommand.Received(1).DeleteAsync(organization); + await sutProvider.GetDependency().Received(1).DeleteAsync(organization); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + SutProvider sutProvider, User user, Organization organization, - OrganizationUser organizationUser - ) + OrganizationUser organizationUser) { - var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] }; + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [organization.Id] }; - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - _policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + sutProvider.GetDependency().GetAsync(user.Id).Returns(policyRequirement); - var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); - await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); - await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); - await _policyRequirementQuery.Received(1).GetAsync(user.Id); + await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await sutProvider.GetDependency().Received(1).GetAsync(user.Id); Assert.True(result.ResetPasswordEnabled); Assert.Equal(result.Id, organization.Id); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( - User user, - Organization organization, - OrganizationUser organizationUser -) + SutProvider sutProvider, + User user, + Organization organization, + OrganizationUser organizationUser) { + var policy = new Policy + { + Type = PolicyType.ResetPassword, + Enabled = true, + Data = "{\"AutoEnrollEnabled\": true}", + OrganizationId = organization.Id + }; - var policy = new Policy() { Type = PolicyType.ResetPassword, Enabled = true, Data = "{\"AutoEnrollEnabled\": true}", OrganizationId = organization.Id }; - - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); - _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + sutProvider.GetDependency().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); - var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); - await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); - await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); - await _policyRequirementQuery.Received(0).GetAsync(user.Id); - await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await sutProvider.GetDependency().Received(0).GetAsync(user.Id); + await sutProvider.GetDependency().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); Assert.True(result.ResetPasswordEnabled); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task PutCollectionManagement_ValidRequest_Success( + SutProvider sutProvider, Organization organization, OrganizationCollectionManagementUpdateRequestModel model) { // Arrange - _currentContext.OrganizationOwner(organization.Id).Returns(true); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); var plan = MockPlans.Get(PlanType.EnterpriseAnnually); - _pricingClient.GetPlan(Arg.Any()).Returns(plan); + sutProvider.GetDependency().GetPlan(Arg.Any()).Returns(plan); - _organizationService + sutProvider.GetDependency() .UpdateCollectionManagementSettingsAsync( organization.Id, Arg.Is(s => @@ -319,10 +249,10 @@ public async Task PutCollectionManagement_ValidRequest_Success( .Returns(organization); // Act - await _sut.PutCollectionManagement(organization.Id, model); + await sutProvider.Sut.PutCollectionManagement(organization.Id, model); // Assert - await _organizationService + await sutProvider.GetDependency() .Received(1) .UpdateCollectionManagementSettingsAsync( organization.Id, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs new file mode 100644 index 000000000000..3a60a6ffd2ca --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs @@ -0,0 +1,414 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationUpdateCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization( + Guid organizationId, + string name, + string billingEmail, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.GatewayCustomerId = null; // No Stripe customer, so no billing update + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = name, + BillingEmail = billingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(name, result.Name); + Assert.Equal(billingEmail.ToLowerInvariant().Trim(), result.BillingEmail); + + await organizationRepository + .Received(1) + .GetByIdAsync(Arg.Is(id => id == organizationId)); + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenOrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, + string name, + string billingEmail, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = name, + BillingEmail = billingEmail + }; + + // Act/Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(request)); + } + + [Theory] + [BitAutoData("")] + [BitAutoData((string)null)] + public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( + string gatewayCustomerId, + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.Name = "Old Name"; + organization.GatewayCustomerId = gatewayCustomerId; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = "New Name", + BillingEmail = organization.BillingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal("New Name", result.Name); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys( + Guid organizationId, + string publicKey, + string encryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.PublicKey = null; + organization.PrivateKey = null; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = organization.Name, + BillingEmail = organization.BillingEmail, + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(publicKey, result.PublicKey); + Assert.Equal(encryptedPrivateKey, result.PrivateKey); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenKeysProvided_AndAlreadySet_DoesNotOverwriteKeys( + Guid organizationId, + string newPublicKey, + string newEncryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + + organization.Id = organizationId; + var existingPublicKey = organization.PublicKey; + var existingPrivateKey = organization.PrivateKey; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = organization.Name, + BillingEmail = organization.BillingEmail, + PublicKey = newPublicKey, + EncryptedPrivateKey = newEncryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(existingPublicKey, result.PublicKey); + Assert.Equal(existingPrivateKey, result.PrivateKey); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UpdatingNameOnly_UpdatesNameAndNotBillingEmail( + Guid organizationId, + string newName, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.Name = "Old Name"; + var originalBillingEmail = organization.BillingEmail; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = newName, + BillingEmail = null + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(newName, result.Name); + Assert.Equal(originalBillingEmail, result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .Received(1) + .UpdateOrganizationNameAndEmail(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UpdatingBillingEmailOnly_UpdatesBillingEmailAndNotName( + Guid organizationId, + string newBillingEmail, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.BillingEmail = "old@example.com"; + var originalName = organization.Name; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = null, + BillingEmail = newBillingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(originalName, result.Name); + Assert.Equal(newBillingEmail.ToLowerInvariant().Trim(), result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .Received(1) + .UpdateOrganizationNameAndEmail(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenNoChanges_PreservesBothFields( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = null, + BillingEmail = null + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(originalName, result.Name); + Assert.Equal(originalBillingEmail, result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_SelfHosted_OnlyUpdatesKeysNotOrganizationDetails( + Guid organizationId, + string newName, + string newBillingEmail, + string publicKey, + string encryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationBillingService = sutProvider.GetDependency(); + var globalSettings = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + + globalSettings.SelfHosted.Returns(true); + + organization.Id = organizationId; + organization.Name = "Original Name"; + organization.BillingEmail = "original@example.com"; + organization.PublicKey = null; + organization.PrivateKey = null; + + organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = newName, // Should be ignored + BillingEmail = newBillingEmail, // Should be ignored + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.Equal("Original Name", result.Name); // Not changed + Assert.Equal("original@example.com", result.BillingEmail); // Not changed + Assert.Equal(publicKey, result.PublicKey); // Changed + Assert.Equal(encryptedPrivateKey, result.PrivateKey); // Changed + + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } +} diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 6a7e9d3190d2..4060b45528b2 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; @@ -353,4 +354,97 @@ await sutProvider.GetDependency() } #endregion + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer( + Organization organization, + SutProvider sutProvider) + { + organization.Name = "Short name"; + + CustomerUpdateOptions capturedOptions = null; + sutProvider.GetDependency() + .CustomerUpdateAsync( + Arg.Is(id => id == organization.GatewayCustomerId), + Arg.Do(options => capturedOptions = options)) + .Returns(new Customer()); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CustomerUpdateAsync( + organization.GatewayCustomerId, + Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.Equal(organization.BillingEmail, capturedOptions.Email); + Assert.Equal(organization.DisplayName(), capturedOptions.Description); + Assert.NotNull(capturedOptions.InvoiceSettings); + Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); + Assert.Single(capturedOptions.InvoiceSettings.CustomFields); + + var customField = capturedOptions.InvoiceSettings.CustomFields.First(); + Assert.Equal(organization.SubscriberType(), customField.Name); + Assert.Equal(organization.DisplayName(), customField.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = "This is a very long organization name that exceeds thirty characters"; + + CustomerUpdateOptions capturedOptions = null; + sutProvider.GetDependency() + .CustomerUpdateAsync( + Arg.Is(id => id == organization.GatewayCustomerId), + Arg.Do(options => capturedOptions = options)) + .Returns(new Customer()); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CustomerUpdateAsync( + organization.GatewayCustomerId, + Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.InvoiceSettings); + Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); + + var customField = capturedOptions.InvoiceSettings.CustomFields.First(); + Assert.Equal(30, customField.Value.Length); + + var expectedCustomFieldDisplayName = "This is a very long organizati"; + Assert.Equal(expectedCustomFieldDisplayName, customField.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.GatewayCustomerId = null; + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@example.com"; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization)); + + Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + } }