mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-25913] Fix owners unable to rename provider-managed organization (#6599)
And other refactors: - move update organization method to a command - separate authorization from business logic - add tests - move Billing Team logic into their service
This commit is contained in:
@@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
@@ -70,6 +69,7 @@ public class OrganizationsController : Controller
|
|||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
||||||
|
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
|
||||||
|
|
||||||
public OrganizationsController(
|
public OrganizationsController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@@ -94,7 +94,8 @@ public class OrganizationsController : Controller
|
|||||||
IOrganizationDeleteCommand organizationDeleteCommand,
|
IOrganizationDeleteCommand organizationDeleteCommand,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
|
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
|
||||||
|
IOrganizationUpdateCommand organizationUpdateCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@@ -119,6 +120,7 @@ public class OrganizationsController : Controller
|
|||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
||||||
|
_organizationUpdateCommand = organizationUpdateCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@@ -224,36 +226,31 @@ public class OrganizationsController : Controller
|
|||||||
return new OrganizationResponseModel(result.Organization, plan);
|
return new OrganizationResponseModel(result.Organization, plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{organizationId:guid}")]
|
||||||
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> 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 (!authorized)
|
||||||
if (organization == null)
|
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
return TypedResults.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateBilling = ShouldUpdateBilling(model, organization);
|
var commandRequest = model.ToCommandRequest(organizationId);
|
||||||
|
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
|
||||||
|
|
||||||
var hasRequiredPermissions = updateBilling
|
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
|
||||||
? await _currentContext.EditSubscription(orgIdGuid)
|
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
|
||||||
: await _currentContext.OrganizationOwner(orgIdGuid);
|
|
||||||
|
|
||||||
if (!hasRequiredPermissions)
|
|
||||||
{
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
|
|
||||||
var plan = await _pricingClient.GetPlan(organization.PlanType);
|
|
||||||
return new OrganizationResponseModel(organization, plan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
||||||
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
return await Put(id, model);
|
return await Put(id, model);
|
||||||
}
|
}
|
||||||
@@ -588,11 +585,4 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return organization.PlanType;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
[Required]
|
|
||||||
[StringLength(256)]
|
|
||||||
public string BillingEmail { get; set; }
|
|
||||||
public Permissions Permissions { get; set; }
|
|
||||||
public OrganizationKeysRequestModel Keys { get; set; }
|
|
||||||
|
|
||||||
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
|
[EmailAddress]
|
||||||
|
[StringLength(256)]
|
||||||
|
public string? BillingEmail { get; set; }
|
||||||
|
|
||||||
|
public OrganizationKeysRequestModel? Keys { get; set; }
|
||||||
|
|
||||||
|
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
|
||||||
{
|
{
|
||||||
if (!globalSettings.SelfHosted)
|
OrganizationId = organizationId,
|
||||||
{
|
Name = Name,
|
||||||
// These items come from the license file
|
BillingEmail = BillingEmail,
|
||||||
existingOrganization.Name = Name;
|
PublicKey = Keys?.PublicKey,
|
||||||
existingOrganization.BusinessName = BusinessName;
|
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
|
||||||
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
|
};
|
||||||
}
|
|
||||||
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; }
|
||||||
|
}
|
||||||
@@ -56,4 +56,15 @@ public interface IOrganizationBillingService
|
|||||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="organization"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="organization"/> is <see langword="null"/>.</exception>
|
||||||
/// <exception cref="BillingException">Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails.</exception>
|
/// <exception cref="BillingException">Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails.</exception>
|
||||||
Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType);
|
Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the organization name and email on the Stripe customer entry.
|
||||||
|
/// This only updates Stripe, not the Bitwarden database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The caller should ensure that the organization has a GatewayCustomerId before calling this method.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="organization">The organization to update in Stripe.</param>
|
||||||
|
/// <exception cref="BillingException">Thrown when the organization does not have a GatewayCustomerId.</exception>
|
||||||
|
Task UpdateOrganizationNameAndEmail(Organization organization);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,35 @@ public class OrganizationBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#region Utilities
|
||||||
|
|
||||||
private async Task<Customer> CreateCustomerAsync(
|
private async Task<Customer> CreateCustomerAsync(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
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;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
@@ -87,6 +88,7 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
private static void AddOrganizationUpdateCommands(this IServiceCollection services)
|
private static void AddOrganizationUpdateCommands(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IOrganizationUpdateKeysCommand, OrganizationUpdateKeysCommand>();
|
services.AddScoped<IOrganizationUpdateKeysCommand, OrganizationUpdateKeysCommand>();
|
||||||
|
services.AddScoped<IOrganizationUpdateCommand, OrganizationUpdateCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
|
private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs
Normal file
77
test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using AutoFixture.Xunit2;
|
|
||||||
using Bit.Api.AdminConsole.Controllers;
|
using Bit.Api.AdminConsole.Controllers;
|
||||||
using Bit.Api.Auth.Models.Request.Accounts;
|
using Bit.Api.Auth.Models.Request.Accounts;
|
||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
@@ -8,9 +7,6 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Models.Business;
|
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.Organizations.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
@@ -20,7 +16,6 @@ using Bit.Core.Auth.Entities;
|
|||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.Services;
|
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Providers.Services;
|
using Bit.Core.Billing.Providers.Services;
|
||||||
@@ -31,101 +26,23 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Test.Billing.Mocks;
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
|
||||||
|
|
||||||
namespace Bit.Api.Test.AdminConsole.Controllers;
|
namespace Bit.Api.Test.AdminConsole.Controllers;
|
||||||
|
|
||||||
public class OrganizationsControllerTests : IDisposable
|
[ControllerCustomize(typeof(OrganizationsController))]
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class OrganizationsControllerTests
|
||||||
{
|
{
|
||||||
private readonly GlobalSettings _globalSettings;
|
[Theory, BitAutoData]
|
||||||
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<OrgDeleteTokenable> _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<ICurrentContext>();
|
|
||||||
_globalSettings = Substitute.For<GlobalSettings>();
|
|
||||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
|
||||||
_organizationService = Substitute.For<IOrganizationService>();
|
|
||||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
|
||||||
_policyRepository = Substitute.For<IPolicyRepository>();
|
|
||||||
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
|
|
||||||
_ssoConfigService = Substitute.For<ISsoConfigService>();
|
|
||||||
_getOrganizationApiKeyQuery = Substitute.For<IGetOrganizationApiKeyQuery>();
|
|
||||||
_rotateOrganizationApiKeyCommand = Substitute.For<IRotateOrganizationApiKeyCommand>();
|
|
||||||
_organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
|
|
||||||
_userService = Substitute.For<IUserService>();
|
|
||||||
_createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>();
|
|
||||||
_featureService = Substitute.For<IFeatureService>();
|
|
||||||
_providerRepository = Substitute.For<IProviderRepository>();
|
|
||||||
_providerBillingService = Substitute.For<IProviderBillingService>();
|
|
||||||
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
|
|
||||||
_removeOrganizationUserCommand = Substitute.For<IRemoveOrganizationUserCommand>();
|
|
||||||
_cloudOrganizationSignUpCommand = Substitute.For<ICloudOrganizationSignUpCommand>();
|
|
||||||
_organizationDeleteCommand = Substitute.For<IOrganizationDeleteCommand>();
|
|
||||||
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
|
|
||||||
_pricingClient = Substitute.For<IPricingClient>();
|
|
||||||
_organizationUpdateKeysCommand = Substitute.For<IOrganizationUpdateKeysCommand>();
|
|
||||||
|
|
||||||
_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]
|
|
||||||
public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvidesKeyConnector(
|
public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvidesKeyConnector(
|
||||||
Guid orgId, User user)
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
|
Guid orgId,
|
||||||
|
User user)
|
||||||
{
|
{
|
||||||
var ssoConfig = new SsoConfig
|
var ssoConfig = new SsoConfig
|
||||||
{
|
{
|
||||||
@@ -140,21 +57,24 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
|
|
||||||
user.UsesKeyConnector = true;
|
user.UsesKeyConnector = true;
|
||||||
|
|
||||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
|
||||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { null });
|
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { null });
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Leave(orgId));
|
||||||
|
|
||||||
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
|
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
|
||||||
exception.Message);
|
exception.Message);
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default);
|
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, AutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser(
|
public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser(
|
||||||
Guid orgId, User user)
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
|
Guid orgId,
|
||||||
|
User user)
|
||||||
{
|
{
|
||||||
var ssoConfig = new SsoConfig
|
var ssoConfig = new SsoConfig
|
||||||
{
|
{
|
||||||
@@ -166,27 +86,34 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
Enabled = true,
|
Enabled = true,
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
};
|
};
|
||||||
var foundOrg = new Organization();
|
var foundOrg = new Organization
|
||||||
foundOrg.Id = orgId;
|
{
|
||||||
|
Id = orgId
|
||||||
|
};
|
||||||
|
|
||||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
|
||||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { { foundOrg } });
|
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { foundOrg });
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
|
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Leave(orgId));
|
||||||
|
|
||||||
Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.",
|
Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.",
|
||||||
exception.Message);
|
exception.Message);
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
|
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineAutoData(true, false)]
|
[BitAutoData(true, false)]
|
||||||
[InlineAutoData(false, true)]
|
[BitAutoData(false, true)]
|
||||||
[InlineAutoData(false, false)]
|
[BitAutoData(false, false)]
|
||||||
public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector(
|
public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector(
|
||||||
bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user)
|
bool keyConnectorEnabled,
|
||||||
|
bool userUsesKeyConnector,
|
||||||
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
|
Guid orgId,
|
||||||
|
User user)
|
||||||
{
|
{
|
||||||
var ssoConfig = new SsoConfig
|
var ssoConfig = new SsoConfig
|
||||||
{
|
{
|
||||||
@@ -203,18 +130,19 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
|
|
||||||
user.UsesKeyConnector = userUsesKeyConnector;
|
user.UsesKeyConnector = userUsesKeyConnector;
|
||||||
|
|
||||||
_currentContext.OrganizationUser(orgId).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
|
||||||
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization>());
|
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization>());
|
||||||
|
|
||||||
await _sut.Leave(orgId);
|
await sutProvider.Sut.Leave(orgId);
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.Received(1).UserLeaveAsync(orgId, user.Id);
|
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().Received(1).UserLeaveAsync(orgId, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, AutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats(
|
public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats(
|
||||||
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
Provider provider,
|
Provider provider,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
User user,
|
User user,
|
||||||
@@ -228,87 +156,89 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
provider.Status = ProviderStatusType.Billable;
|
provider.Status = ProviderStatusType.Billable;
|
||||||
|
|
||||||
_currentContext.OrganizationOwner(organizationId).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organizationId).Returns(true);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||||
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
|
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, requestModel.Secret).Returns(true);
|
||||||
|
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||||
|
|
||||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
await sutProvider.Sut.Delete(organizationId.ToString(), requestModel);
|
||||||
|
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||||
|
|
||||||
_userService.VerifySecretAsync(user, requestModel.Secret).Returns(true);
|
|
||||||
|
|
||||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
|
||||||
|
|
||||||
await _sut.Delete(organizationId.ToString(), requestModel);
|
|
||||||
|
|
||||||
await _providerBillingService.Received(1)
|
|
||||||
.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
|
||||||
await _organizationDeleteCommand.Received(1).DeleteAsync(organization);
|
await sutProvider.GetDependency<IOrganizationDeleteCommand>().Received(1).DeleteAsync(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, AutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
|
public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
|
||||||
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
User user,
|
User user,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
OrganizationUser organizationUser
|
OrganizationUser organizationUser)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] };
|
var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [organization.Id] };
|
||||||
|
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
_organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
|
||||||
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
|
||||||
_policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
|
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<ResetPasswordPolicyRequirement>(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<ClaimsPrincipal>());
|
await sutProvider.GetDependency<IUserService>().Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
|
||||||
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
|
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdentifierAsync(organization.Id.ToString());
|
||||||
await _policyRequirementQuery.Received(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
|
await sutProvider.GetDependency<IPolicyRequirementQuery>().Received(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
|
||||||
|
|
||||||
Assert.True(result.ResetPasswordEnabled);
|
Assert.True(result.ResetPasswordEnabled);
|
||||||
Assert.Equal(result.Id, organization.Id);
|
Assert.Equal(result.Id, organization.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, AutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
|
public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
|
||||||
User user,
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
Organization organization,
|
User user,
|
||||||
OrganizationUser organizationUser
|
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 };
|
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
|
||||||
|
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
|
||||||
|
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy);
|
||||||
|
|
||||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString());
|
||||||
_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);
|
|
||||||
|
|
||||||
var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
|
await sutProvider.GetDependency<IUserService>().Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
|
||||||
|
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdentifierAsync(organization.Id.ToString());
|
||||||
await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
|
await sutProvider.GetDependency<IPolicyRequirementQuery>().Received(0).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
|
||||||
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
|
await sutProvider.GetDependency<IPolicyRepository>().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||||
await _policyRequirementQuery.Received(0).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
|
|
||||||
await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
|
||||||
|
|
||||||
Assert.True(result.ResetPasswordEnabled);
|
Assert.True(result.ResetPasswordEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, AutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task PutCollectionManagement_ValidRequest_Success(
|
public async Task PutCollectionManagement_ValidRequest_Success(
|
||||||
|
SutProvider<OrganizationsController> sutProvider,
|
||||||
Organization organization,
|
Organization organization,
|
||||||
OrganizationCollectionManagementUpdateRequestModel model)
|
OrganizationCollectionManagementUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
_currentContext.OrganizationOwner(organization.Id).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
|
|
||||||
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
|
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
|
||||||
_pricingClient.GetPlan(Arg.Any<PlanType>()).Returns(plan);
|
sutProvider.GetDependency<IPricingClient>().GetPlan(Arg.Any<PlanType>()).Returns(plan);
|
||||||
|
|
||||||
_organizationService
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
.UpdateCollectionManagementSettingsAsync(
|
.UpdateCollectionManagementSettingsAsync(
|
||||||
organization.Id,
|
organization.Id,
|
||||||
Arg.Is<OrganizationCollectionManagementSettings>(s =>
|
Arg.Is<OrganizationCollectionManagementSettings>(s =>
|
||||||
@@ -319,10 +249,10 @@ public class OrganizationsControllerTests : IDisposable
|
|||||||
.Returns(organization);
|
.Returns(organization);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.PutCollectionManagement(organization.Id, model);
|
await sutProvider.Sut.PutCollectionManagement(organization.Id, model);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _organizationService
|
await sutProvider.GetDependency<IOrganizationService>()
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.UpdateCollectionManagementSettingsAsync(
|
.UpdateCollectionManagementSettingsAsync(
|
||||||
organization.Id,
|
organization.Id,
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Models.Sales;
|
using Bit.Core.Billing.Models.Sales;
|
||||||
@@ -353,4 +354,97 @@ public class OrganizationBillingServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<OrganizationBillingService> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Name = "Short name";
|
||||||
|
|
||||||
|
CustomerUpdateOptions capturedOptions = null;
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.CustomerUpdateAsync(
|
||||||
|
Arg.Is<string>(id => id == organization.GatewayCustomerId),
|
||||||
|
Arg.Do<CustomerUpdateOptions>(options => capturedOptions = options))
|
||||||
|
.Returns(new Customer());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.Received(1)
|
||||||
|
.CustomerUpdateAsync(
|
||||||
|
organization.GatewayCustomerId,
|
||||||
|
Arg.Any<CustomerUpdateOptions>());
|
||||||
|
|
||||||
|
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<OrganizationBillingService> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organization.Name = "This is a very long organization name that exceeds thirty characters";
|
||||||
|
|
||||||
|
CustomerUpdateOptions capturedOptions = null;
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.CustomerUpdateAsync(
|
||||||
|
Arg.Is<string>(id => id == organization.GatewayCustomerId),
|
||||||
|
Arg.Do<CustomerUpdateOptions>(options => capturedOptions = options))
|
||||||
|
.Returns(new Customer());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.Received(1)
|
||||||
|
.CustomerUpdateAsync(
|
||||||
|
organization.GatewayCustomerId,
|
||||||
|
Arg.Any<CustomerUpdateOptions>());
|
||||||
|
|
||||||
|
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<OrganizationBillingService> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
organization.GatewayCustomerId = null;
|
||||||
|
organization.Name = "Test Organization";
|
||||||
|
organization.BillingEmail = "billing@example.com";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BillingException>(
|
||||||
|
() => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization));
|
||||||
|
|
||||||
|
Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IStripeAdapter>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user