Browse Source
And other refactors: - move update organization method to a command - separate authorization from business logic - add tests - move Billing Team logic into their servicepull/6646/head
14 changed files with 1121 additions and 223 deletions
@ -1,41 +1,28 @@ |
|||||||
// FIXME: Update this file to be null safe and then delete the line below |
using System.ComponentModel.DataAnnotations; |
||||||
#nullable disable |
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations; |
|
||||||
using System.Text.Json.Serialization; |
using System.Text.Json.Serialization; |
||||||
using Bit.Core.AdminConsole.Entities; |
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; |
||||||
using Bit.Core.Models.Data; |
|
||||||
using Bit.Core.Settings; |
|
||||||
using Bit.Core.Utilities; |
using Bit.Core.Utilities; |
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations; |
namespace Bit.Api.AdminConsole.Models.Request.Organizations; |
||||||
|
|
||||||
public class OrganizationUpdateRequestModel |
public class OrganizationUpdateRequestModel |
||||||
{ |
{ |
||||||
[Required] |
|
||||||
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] |
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] |
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))] |
[JsonConverter(typeof(HtmlEncodingStringConverter))] |
||||||
public string Name { get; set; } |
public string? Name { get; set; } |
||||||
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] |
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))] |
|
||||||
public string BusinessName { get; set; } |
|
||||||
[EmailAddress] |
[EmailAddress] |
||||||
[Required] |
|
||||||
[StringLength(256)] |
[StringLength(256)] |
||||||
public string BillingEmail { get; set; } |
public string? BillingEmail { get; set; } |
||||||
public Permissions Permissions { get; set; } |
|
||||||
public OrganizationKeysRequestModel Keys { get; set; } |
|
||||||
|
|
||||||
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings) |
public OrganizationKeysRequestModel? Keys { get; set; } |
||||||
{ |
|
||||||
if (!globalSettings.SelfHosted) |
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new() |
||||||
{ |
{ |
||||||
// These items come from the license file |
OrganizationId = organizationId, |
||||||
existingOrganization.Name = Name; |
Name = Name, |
||||||
existingOrganization.BusinessName = BusinessName; |
BillingEmail = BillingEmail, |
||||||
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); |
PublicKey = Keys?.PublicKey, |
||||||
} |
EncryptedPrivateKey = Keys?.EncryptedPrivateKey |
||||||
Keys?.ToOrganization(existingOrganization); |
}; |
||||||
return existingOrganization; |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// 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. |
||||||
|
/// </summary> |
||||||
|
/// <param name="request">The update request containing the details to be updated.</param> |
||||||
|
Task<Organization> UpdateAsync(OrganizationUpdateRequest request); |
||||||
|
} |
||||||
@ -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<Organization> 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<Organization> 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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// 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. |
||||||
|
/// </summary> |
||||||
|
private async Task<Organization> 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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
using Bit.Core.AdminConsole.Entities; |
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; |
||||||
|
|
||||||
|
public static class OrganizationUpdateExtensions |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Updates the organization name and/or billing email. |
||||||
|
/// Any null property on the request object will be skipped. |
||||||
|
/// </summary> |
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// 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. |
||||||
|
/// </summary> |
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// 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. |
||||||
|
/// </summary> |
||||||
|
public record OrganizationUpdateRequest |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// The ID of the organization to update. |
||||||
|
/// </summary> |
||||||
|
public required Guid OrganizationId { get; init; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// The new organization name to apply (optional, this is skipped if not provided). |
||||||
|
/// </summary> |
||||||
|
public string? Name { get; init; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// The new billing email address to apply (optional, this is skipped if not provided). |
||||||
|
/// </summary> |
||||||
|
public string? BillingEmail { get; init; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// The organization's public key to set (optional, only set if not already present on the organization). |
||||||
|
/// </summary> |
||||||
|
public string? PublicKey { get; init; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// The organization's encrypted private key to set (optional, only set if not already present on the organization). |
||||||
|
/// </summary> |
||||||
|
public string? EncryptedPrivateKey { get; init; } |
||||||
|
} |
||||||
@ -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<ApiApplicationFactory>, 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<IOrganizationRepository>(); |
||||||
|
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<IOrganizationRepository>(); |
||||||
|
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<IOrganizationRepository>(); |
||||||
|
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<IOrganizationRepository>(); |
||||||
|
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<IOrganizationRepository>(); |
||||||
|
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); |
||||||
|
Assert.NotNull(updatedOrg); |
||||||
|
Assert.Equal(_organizationName, updatedOrg.Name); |
||||||
|
Assert.Equal(_billingEmail, updatedOrg.BillingEmail); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Creates a provider and links it to an organization. |
||||||
|
/// This does NOT create any provider users. |
||||||
|
/// </summary> |
||||||
|
/// <param name="factory">The API application factory</param> |
||||||
|
/// <param name="organizationId">The organization ID to link to the provider</param> |
||||||
|
/// <param name="providerType">The type of provider to create</param> |
||||||
|
/// <param name="providerStatus">The provider status (defaults to Created)</param> |
||||||
|
/// <returns>The created provider</returns> |
||||||
|
public static async Task<Provider> CreateProviderAndLinkToOrganizationAsync( |
||||||
|
ApiApplicationFactory factory, |
||||||
|
Guid organizationId, |
||||||
|
ProviderType providerType, |
||||||
|
ProviderStatusType providerStatus = ProviderStatusType.Created) |
||||||
|
{ |
||||||
|
var providerRepository = factory.GetService<IProviderRepository>(); |
||||||
|
var providerOrganizationRepository = factory.GetService<IProviderOrganizationRepository>(); |
||||||
|
|
||||||
|
// 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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Creates a providerUser for a provider. |
||||||
|
/// </summary> |
||||||
|
public static async Task<ProviderUser> CreateProviderUserAsync( |
||||||
|
ApiApplicationFactory factory, |
||||||
|
Guid providerId, |
||||||
|
string userEmail, |
||||||
|
ProviderUserType providerUserType) |
||||||
|
{ |
||||||
|
var userRepository = factory.GetService<IUserRepository>(); |
||||||
|
var user = await userRepository.GetByEmailAsync(userEmail); |
||||||
|
if (user is null) |
||||||
|
{ |
||||||
|
throw new Exception("No user found in test setup."); |
||||||
|
} |
||||||
|
|
||||||
|
var providerUserRepository = factory.GetService<IProviderUserRepository>(); |
||||||
|
return await providerUserRepository.CreateAsync(new ProviderUser |
||||||
|
{ |
||||||
|
ProviderId = providerId, |
||||||
|
Status = ProviderUserStatusType.Confirmed, |
||||||
|
UserId = user.Id, |
||||||
|
Key = Guid.NewGuid().ToString(), |
||||||
|
Type = providerUserType |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
|
||||||
|
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<Guid>(id => id == organizationId)); |
||||||
|
await organizationService |
||||||
|
.Received(1) |
||||||
|
.ReplaceAndUpdateCacheAsync( |
||||||
|
result, |
||||||
|
EventType.Organization_Updated); |
||||||
|
await organizationBillingService |
||||||
|
.DidNotReceiveWithAnyArgs() |
||||||
|
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>()); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory, BitAutoData] |
||||||
|
public async Task UpdateAsync_WhenOrganizationNotFound_ThrowsNotFoundException( |
||||||
|
Guid organizationId, |
||||||
|
string name, |
||||||
|
string billingEmail, |
||||||
|
SutProvider<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
|
||||||
|
organizationRepository |
||||||
|
.GetByIdAsync(organizationId) |
||||||
|
.Returns((Organization)null); |
||||||
|
|
||||||
|
var request = new OrganizationUpdateRequest |
||||||
|
{ |
||||||
|
OrganizationId = organizationId, |
||||||
|
Name = name, |
||||||
|
BillingEmail = billingEmail |
||||||
|
}; |
||||||
|
|
||||||
|
// Act/Assert |
||||||
|
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateAsync(request)); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory] |
||||||
|
[BitAutoData("")] |
||||||
|
[BitAutoData((string)null)] |
||||||
|
public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( |
||||||
|
string gatewayCustomerId, |
||||||
|
Guid organizationId, |
||||||
|
Organization organization, |
||||||
|
SutProvider<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
|
||||||
|
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<Organization>()); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory, BitAutoData] |
||||||
|
public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys( |
||||||
|
Guid organizationId, |
||||||
|
string publicKey, |
||||||
|
string encryptedPrivateKey, |
||||||
|
Organization organization, |
||||||
|
SutProvider<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
|
||||||
|
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<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
|
||||||
|
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<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
|
||||||
|
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<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
|
||||||
|
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<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
var organizationService = sutProvider.GetDependency<IOrganizationService>(); |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
|
||||||
|
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<Organization>()); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory, BitAutoData] |
||||||
|
public async Task UpdateAsync_SelfHosted_OnlyUpdatesKeysNotOrganizationDetails( |
||||||
|
Guid organizationId, |
||||||
|
string newName, |
||||||
|
string newBillingEmail, |
||||||
|
string publicKey, |
||||||
|
string encryptedPrivateKey, |
||||||
|
Organization organization, |
||||||
|
SutProvider<OrganizationUpdateCommand> sutProvider) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>(); |
||||||
|
var globalSettings = sutProvider.GetDependency<IGlobalSettings>(); |
||||||
|
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>(); |
||||||
|
|
||||||
|
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<Organization>()); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue