1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 05:03:18 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-10-28 14:10:57 -04:00
committed by GitHub
96 changed files with 17627 additions and 1014 deletions

View File

@@ -0,0 +1,150 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response;
public class ProfileOrganizationResponseModelTests
{
[Theory, BitAutoData]
public void Constructor_ShouldPopulatePropertiesCorrectly(Organization organization)
{
var userId = Guid.NewGuid();
var organizationUserId = Guid.NewGuid();
var providerId = Guid.NewGuid();
var organizationIdsClaimingUser = new[] { organization.Id };
var organizationDetails = new OrganizationUserOrganizationDetails
{
OrganizationId = organization.Id,
UserId = userId,
OrganizationUserId = organizationUserId,
Name = organization.Name,
Enabled = organization.Enabled,
Identifier = organization.Identifier,
PlanType = organization.PlanType,
UsePolicies = organization.UsePolicies,
UseSso = organization.UseSso,
UseKeyConnector = organization.UseKeyConnector,
UseScim = organization.UseScim,
UseGroups = organization.UseGroups,
UseDirectory = organization.UseDirectory,
UseEvents = organization.UseEvents,
UseTotp = organization.UseTotp,
Use2fa = organization.Use2fa,
UseApi = organization.UseApi,
UseResetPassword = organization.UseResetPassword,
UseSecretsManager = organization.UseSecretsManager,
UsePasswordManager = organization.UsePasswordManager,
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
SelfHost = organization.SelfHost,
Seats = organization.Seats,
MaxCollections = organization.MaxCollections,
MaxStorageGb = organization.MaxStorageGb,
Key = "organization-key",
PublicKey = "public-key",
PrivateKey = "private-key",
LimitCollectionCreation = organization.LimitCollectionCreation,
LimitCollectionDeletion = organization.LimitCollectionDeletion,
LimitItemDeletion = organization.LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems,
ProviderId = providerId,
ProviderName = "Test Provider",
ProviderType = ProviderType.Msp,
SsoEnabled = true,
SsoConfig = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize(),
SsoExternalId = "external-sso-id",
Permissions = CoreHelpers.ClassToJsonData(new Core.Models.Data.Permissions { ManageUsers = true }),
ResetPasswordKey = "reset-password-key",
FamilySponsorshipFriendlyName = "Family Sponsorship",
FamilySponsorshipLastSyncDate = DateTime.UtcNow.AddDays(-1),
FamilySponsorshipToDelete = false,
FamilySponsorshipValidUntil = DateTime.UtcNow.AddYears(1),
IsAdminInitiated = true,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
AccessSecretsManager = true,
SmSeats = 5,
SmServiceAccounts = 10
};
var result = new ProfileOrganizationResponseModel(organizationDetails, organizationIdsClaimingUser);
Assert.Equal("profileOrganization", result.Object);
Assert.Equal(organization.Id, result.Id);
Assert.Equal(userId, result.UserId);
Assert.Equal(organization.Name, result.Name);
Assert.Equal(organization.Enabled, result.Enabled);
Assert.Equal(organization.Identifier, result.Identifier);
Assert.Equal(organization.PlanType.GetProductTier(), result.ProductTierType);
Assert.Equal(organization.UsePolicies, result.UsePolicies);
Assert.Equal(organization.UseSso, result.UseSso);
Assert.Equal(organization.UseKeyConnector, result.UseKeyConnector);
Assert.Equal(organization.UseScim, result.UseScim);
Assert.Equal(organization.UseGroups, result.UseGroups);
Assert.Equal(organization.UseDirectory, result.UseDirectory);
Assert.Equal(organization.UseEvents, result.UseEvents);
Assert.Equal(organization.UseTotp, result.UseTotp);
Assert.Equal(organization.Use2fa, result.Use2fa);
Assert.Equal(organization.UseApi, result.UseApi);
Assert.Equal(organization.UseResetPassword, result.UseResetPassword);
Assert.Equal(organization.UseSecretsManager, result.UseSecretsManager);
Assert.Equal(organization.UsePasswordManager, result.UsePasswordManager);
Assert.Equal(organization.UsersGetPremium, result.UsersGetPremium);
Assert.Equal(organization.UseCustomPermissions, result.UseCustomPermissions);
Assert.Equal(organization.PlanType.GetProductTier() == ProductTierType.Enterprise, result.UseActivateAutofillPolicy);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
Assert.Equal(organization.SelfHost, result.SelfHost);
Assert.Equal(organization.Seats, result.Seats);
Assert.Equal(organization.MaxCollections, result.MaxCollections);
Assert.Equal(organization.MaxStorageGb, result.MaxStorageGb);
Assert.Equal(organizationDetails.Key, result.Key);
Assert.True(result.HasPublicAndPrivateKeys);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organizationDetails.ProviderId, result.ProviderId);
Assert.Equal(organizationDetails.ProviderName, result.ProviderName);
Assert.Equal(organizationDetails.ProviderType, result.ProviderType);
Assert.Equal(organizationDetails.SsoEnabled, result.SsoEnabled);
Assert.True(result.KeyConnectorEnabled);
Assert.Equal("https://keyconnector.example.com", result.KeyConnectorUrl);
Assert.Equal(MemberDecryptionType.KeyConnector, result.SsoMemberDecryptionType);
Assert.True(result.SsoBound);
Assert.Equal(organizationDetails.Status, result.Status);
Assert.Equal(organizationDetails.Type, result.Type);
Assert.Equal(organizationDetails.OrganizationUserId, result.OrganizationUserId);
Assert.True(result.UserIsClaimedByOrganization);
Assert.NotNull(result.Permissions);
Assert.True(result.ResetPasswordEnrolled);
Assert.Equal(organizationDetails.AccessSecretsManager, result.AccessSecretsManager);
Assert.Equal(organizationDetails.FamilySponsorshipFriendlyName, result.FamilySponsorshipFriendlyName);
Assert.Equal(organizationDetails.FamilySponsorshipLastSyncDate, result.FamilySponsorshipLastSyncDate);
Assert.Equal(organizationDetails.FamilySponsorshipToDelete, result.FamilySponsorshipToDelete);
Assert.Equal(organizationDetails.FamilySponsorshipValidUntil, result.FamilySponsorshipValidUntil);
Assert.True(result.IsAdminInitiated);
Assert.False(result.FamilySponsorshipAvailable);
}
}

View File

@@ -0,0 +1,129 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response;
public class ProfileProviderOrganizationResponseModelTests
{
[Theory, BitAutoData]
public void Constructor_ShouldPopulatePropertiesCorrectly(Organization organization)
{
var userId = Guid.NewGuid();
var providerId = Guid.NewGuid();
var providerUserId = Guid.NewGuid();
var organizationDetails = new ProviderUserOrganizationDetails
{
OrganizationId = organization.Id,
UserId = userId,
Name = organization.Name,
Enabled = organization.Enabled,
Identifier = organization.Identifier,
PlanType = organization.PlanType,
UsePolicies = organization.UsePolicies,
UseSso = organization.UseSso,
UseKeyConnector = organization.UseKeyConnector,
UseScim = organization.UseScim,
UseGroups = organization.UseGroups,
UseDirectory = organization.UseDirectory,
UseEvents = organization.UseEvents,
UseTotp = organization.UseTotp,
Use2fa = organization.Use2fa,
UseApi = organization.UseApi,
UseResetPassword = organization.UseResetPassword,
UseSecretsManager = organization.UseSecretsManager,
UsePasswordManager = organization.UsePasswordManager,
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
SelfHost = organization.SelfHost,
Seats = organization.Seats,
MaxCollections = organization.MaxCollections,
MaxStorageGb = organization.MaxStorageGb,
Key = "provider-org-key",
PublicKey = "public-key",
PrivateKey = "private-key",
LimitCollectionCreation = organization.LimitCollectionCreation,
LimitCollectionDeletion = organization.LimitCollectionDeletion,
LimitItemDeletion = organization.LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems,
ProviderId = providerId,
ProviderName = "Test MSP Provider",
ProviderType = ProviderType.Msp,
SsoEnabled = true,
SsoConfig = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption
}.Serialize(),
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin,
ProviderUserId = providerUserId
};
var result = new ProfileProviderOrganizationResponseModel(organizationDetails);
Assert.Equal("profileProviderOrganization", result.Object);
Assert.Equal(organization.Id, result.Id);
Assert.Equal(userId, result.UserId);
Assert.Equal(organization.Name, result.Name);
Assert.Equal(organization.Enabled, result.Enabled);
Assert.Equal(organization.Identifier, result.Identifier);
Assert.Equal(organization.PlanType.GetProductTier(), result.ProductTierType);
Assert.Equal(organization.UsePolicies, result.UsePolicies);
Assert.Equal(organization.UseSso, result.UseSso);
Assert.Equal(organization.UseKeyConnector, result.UseKeyConnector);
Assert.Equal(organization.UseScim, result.UseScim);
Assert.Equal(organization.UseGroups, result.UseGroups);
Assert.Equal(organization.UseDirectory, result.UseDirectory);
Assert.Equal(organization.UseEvents, result.UseEvents);
Assert.Equal(organization.UseTotp, result.UseTotp);
Assert.Equal(organization.Use2fa, result.Use2fa);
Assert.Equal(organization.UseApi, result.UseApi);
Assert.Equal(organization.UseResetPassword, result.UseResetPassword);
Assert.Equal(organization.UseSecretsManager, result.UseSecretsManager);
Assert.Equal(organization.UsePasswordManager, result.UsePasswordManager);
Assert.Equal(organization.UsersGetPremium, result.UsersGetPremium);
Assert.Equal(organization.UseCustomPermissions, result.UseCustomPermissions);
Assert.Equal(organization.PlanType.GetProductTier() == ProductTierType.Enterprise, result.UseActivateAutofillPolicy);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
Assert.Equal(organization.SelfHost, result.SelfHost);
Assert.Equal(organization.Seats, result.Seats);
Assert.Equal(organization.MaxCollections, result.MaxCollections);
Assert.Equal(organization.MaxStorageGb, result.MaxStorageGb);
Assert.Equal(organizationDetails.Key, result.Key);
Assert.True(result.HasPublicAndPrivateKeys);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organizationDetails.ProviderId, result.ProviderId);
Assert.Equal(organizationDetails.ProviderName, result.ProviderName);
Assert.Equal(organizationDetails.ProviderType, result.ProviderType);
Assert.Equal(OrganizationUserStatusType.Confirmed, result.Status);
Assert.Equal(OrganizationUserType.Owner, result.Type);
Assert.Equal(organizationDetails.SsoEnabled, result.SsoEnabled);
Assert.False(result.KeyConnectorEnabled);
Assert.Null(result.KeyConnectorUrl);
Assert.Equal(MemberDecryptionType.TrustedDeviceEncryption, result.SsoMemberDecryptionType);
Assert.False(result.SsoBound);
Assert.NotNull(result.Permissions);
Assert.False(result.Permissions.ManageUsers);
Assert.False(result.ResetPasswordEnrolled);
Assert.False(result.AccessSecretsManager);
}
}

View File

@@ -285,6 +285,10 @@ public class SyncControllerTests
providerUserRepository
.GetManyDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed).Returns(providerUserDetails);
foreach (var p in providerUserOrganizationDetails)
{
p.SsoConfig = null;
}
providerUserRepository
.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed)
.Returns(providerUserOrganizationDetails);

View File

@@ -0,0 +1,391 @@
using Bit.Billing.Controllers;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Transaction = Bit.Core.Entities.Transaction;
namespace Bit.Billing.Test.Controllers;
using static BitPayConstants;
public class BitPayControllerTests
{
private readonly GlobalSettings _globalSettings = new();
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>();
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IMailService _mailService = Substitute.For<IMailService>();
private readonly IPaymentService _paymentService = Substitute.For<IPaymentService>();
private readonly IPremiumUserBillingService _premiumUserBillingService =
Substitute.For<IPremiumUserBillingService>();
private const string _validWebhookKey = "valid-webhook-key";
private const string _invalidWebhookKey = "invalid-webhook-key";
public BitPayControllerTests()
{
var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey };
_globalSettings.BitPay = bitPaySettings;
}
private BitPayController CreateController() => new(
_globalSettings,
_bitPayClient,
_transactionRepository,
_organizationRepository,
_userRepository,
_providerRepository,
_mailService,
_paymentService,
Substitute.For<ILogger<BitPayController>>(),
_premiumUserBillingService);
[Fact]
public async Task PostIpn_InvalidKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, _invalidWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullKey_ThrowsException()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
await Assert.ThrowsAsync<ArgumentNullException>(() => controller.PostIpn(eventModel, null!));
}
[Fact]
public async Task PostIpn_EmptyKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, string.Empty);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NonUsdCurrency_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(currency: "EUR");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Cannot process non-USD payments", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: null!);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_EmptyPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutValidId_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_IncompleteInvoice_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(status: "paid");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Waiting for invoice to be completed", okResult.Value);
}
[Fact]
public async Task PostIpn_ExistingTransaction_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice();
var existingTransaction = new Transaction { GatewayId = invoice.Id };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Invoice already processed", okResult.Value);
}
[Fact]
public async Task PostIpn_ValidOrganizationTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_paymentService.CreditAccountAsync(organization, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.OrganizationId == organizationId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _organizationRepository.Received(1).ReplaceAsync(organization);
await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidUserTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var user = new User { Id = userId, Email = "user@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_userRepository.GetByIdAsync(userId).Returns(user);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.UserId == userId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _premiumUserBillingService.Received(1).Credit(user, 100.00m);
await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidProviderTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider));
_paymentService.CreditAccountAsync(provider, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.ProviderId == providerId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _providerRepository.Received(1).ReplaceAsync(provider);
await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m);
}
[Fact]
public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId()
{
var controller = CreateController();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Equal(organizationId, result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId()
{
var controller = CreateController();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Equal(userId, result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId()
{
var controller = CreateController();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Equal(providerId, result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_InvalidGuid_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_NullPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: null!);
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_EmptyPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id")
{
return new BitPayEventModel
{
Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" },
Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId }
};
}
private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete",
string currency = "USD", decimal price = 100.00m,
string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1")
{
return new Invoice
{
Id = invoiceId,
Status = status,
Currency = currency,
Price = (double)price,
PosData = posData,
CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Transactions =
[
new InvoiceTransaction
{
Type = null,
Confirmations = "1",
ReceivedTime = DateTime.UtcNow.ToString("O")
}
]
};
}
}

View File

@@ -0,0 +1,234 @@
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Quartz;
using Xunit;
namespace Bit.Billing.Test.Jobs;
public class ProviderOrganizationDisableJobTests
{
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly ILogger<ProviderOrganizationDisableJob> _logger;
private readonly ProviderOrganizationDisableJob _sut;
public ProviderOrganizationDisableJobTests()
{
_providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_logger = Substitute.For<ILogger<ProviderOrganizationDisableJob>>();
_sut = new ProviderOrganizationDisableJob(
_providerOrganizationRepository,
_organizationDisableCommand,
_logger);
}
[Fact]
public async Task Execute_NoOrganizations_LogsAndReturns()
{
// Arrange
var providerId = Guid.NewGuid();
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns((ICollection<ProviderOrganizationOrganizationDetails>)null);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
}
[Fact]
public async Task Execute_WithOrganizations_DisablesAllOrganizations()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var org1Id = Guid.NewGuid();
var org2Id = Guid.NewGuid();
var org3Id = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = org1Id },
new() { OrganizationId = org2Id },
new() { OrganizationId = org3Id }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_WithExpirationDate_PassesDateToDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = new DateTime(2025, 12, 31, 23, 59, 59);
var orgId = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = orgId }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(orgId, expirationDate);
}
[Fact]
public async Task Execute_WithNullExpirationDate_PassesNullToDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var orgId = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = orgId }
};
var context = CreateJobExecutionContext(providerId, null);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(orgId, null);
}
[Fact]
public async Task Execute_OneOrganizationFails_ContinuesProcessingOthers()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var org1Id = Guid.NewGuid();
var org2Id = Guid.NewGuid();
var org3Id = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = org1Id },
new() { OrganizationId = org2Id },
new() { OrganizationId = org3Id }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Make org2 fail
_organizationDisableCommand.DisableAsync(org2Id, Arg.Any<DateTime?>())
.Throws(new Exception("Database error"));
// Act
await _sut.Execute(context);
// Assert - all three should be attempted
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_ManyOrganizations_ProcessesWithLimitedConcurrency()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
// Create 20 organizations
var organizations = Enumerable.Range(1, 20)
.Select(_ => new ProviderOrganizationOrganizationDetails { OrganizationId = Guid.NewGuid() })
.ToList();
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
var concurrentCalls = 0;
var maxConcurrentCalls = 0;
var lockObj = new object();
_organizationDisableCommand.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>())
.Returns(callInfo =>
{
lock (lockObj)
{
concurrentCalls++;
if (concurrentCalls > maxConcurrentCalls)
{
maxConcurrentCalls = concurrentCalls;
}
}
return Task.Delay(50).ContinueWith(_ =>
{
lock (lockObj)
{
concurrentCalls--;
}
});
});
// Act
await _sut.Execute(context);
// Assert
Assert.True(maxConcurrentCalls <= 5, $"Expected max concurrency of 5, but got {maxConcurrentCalls}");
await _organizationDisableCommand.Received(20).DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_EmptyOrganizationsList_DoesNotCallDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(new List<ProviderOrganizationOrganizationDetails>());
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
}
private static IJobExecutionContext CreateJobExecutionContext(Guid providerId, DateTime? expirationDate)
{
var context = Substitute.For<IJobExecutionContext>();
var jobDataMap = new JobDataMap
{
{ "providerId", providerId.ToString() },
{ "expirationDate", expirationDate?.ToString("O") }
};
context.MergedJobDataMap.Returns(jobDataMap);
return context;
}
}

View File

@@ -1,10 +1,15 @@
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using NSubstitute;
using Quartz;
using Stripe;
using Xunit;
@@ -16,6 +21,10 @@ public class SubscriptionDeletedHandlerTests
private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IScheduler _scheduler;
private readonly SubscriptionDeletedHandler _sut;
public SubscriptionDeletedHandlerTests()
@@ -24,11 +33,19 @@ public class SubscriptionDeletedHandlerTests
_userService = Substitute.For<IUserService>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_providerRepository = Substitute.For<IProviderRepository>();
_providerService = Substitute.For<IProviderService>();
_schedulerFactory = Substitute.For<ISchedulerFactory>();
_scheduler = Substitute.For<IScheduler>();
_schedulerFactory.GetScheduler().Returns(_scheduler);
_sut = new SubscriptionDeletedHandler(
_stripeEventService,
_userService,
_stripeEventUtilityService,
_organizationDisableCommand);
_organizationDisableCommand,
_providerRepository,
_providerService,
_schedulerFactory);
}
[Fact]
@@ -59,6 +76,7 @@ public class SubscriptionDeletedHandlerTests
// Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default);
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
}
[Fact]
@@ -192,4 +210,120 @@ public class SubscriptionDeletedHandlerTests
await _organizationDisableCommand.DidNotReceiveWithAnyArgs()
.DisableAsync(default, default);
}
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_DisablesProviderAndQueuesJob()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var provider = new Provider
{
Id = providerId,
Enabled = true
};
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(stripeEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _scheduler.Received(1).ScheduleJob(
Arg.Is<IJobDetail>(j => j.JobType == typeof(ProviderOrganizationDisableJob)),
Arg.Any<ITrigger>());
}
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_ProviderNotFound_DoesNotThrow()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns((Provider)null);
// Act & Assert - Should not throw
await _sut.HandleAsync(stripeEvent);
// Assert
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default, default);
}
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_QueuesJobWithCorrectParameters()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var provider = new Provider
{
Id = providerId,
Enabled = true
};
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = expirationDate }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(stripeEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _scheduler.Received(1).ScheduleJob(
Arg.Is<IJobDetail>(j =>
j.JobType == typeof(ProviderOrganizationDisableJob) &&
j.JobDataMap.GetString("providerId") == providerId.ToString() &&
j.JobDataMap.GetString("expirationDate") == expirationDate.ToString("O")),
Arg.Is<ITrigger>(t => t.Key.Name == $"disable-trigger-{providerId}"));
}
}

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Entities;
@@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice;
namespace Bit.Core.Test.Billing.Payment.Commands;
using static BitPayConstants;
public class CreateBitPayInvoiceForCreditCommandTests
{
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly GlobalSettings _globalSettings = new()
{
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" }
BitPay = new GlobalSettings.BitPaySettings
{
NotificationUrl = "https://example.com/bitpay/notification",
WebhookKey = "test-webhook-key"
}
};
private const string _redirectUrl = "https://bitwarden.com/redirect";
private readonly CreateBitPayInvoiceForCreditCommand _command;
@@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == user.Email &&
options.Buyer.Name == user.Email &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"userId:{user.Id},accountCredit:1" &&
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
@@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == organization.BillingEmail &&
options.Buyer.Name == organization.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"organizationId:{organization.Id},accountCredit:1" &&
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
@@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == provider.BillingEmail &&
options.Buyer.Name == provider.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
options.PosData == $"providerId:{provider.Id},accountCredit:1" &&
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using Bit.Core.Billing.Payment.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Models;
public class PaymentMethodTests
{
[Theory]
[InlineData("{\"cardNumber\":\"1234\"}")]
[InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")]
[InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")]
[InlineData("{\"type\":\"invalid\"}")]
public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
[Theory]
[InlineData("{\"type\":\"card\"}")]
[InlineData("{\"type\":\"card\",\"token\":\"\"}")]
[InlineData("{\"type\":\"card\",\"token\":null}")]
public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
// Tokenized payment method deserialization
[Theory]
[InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)]
[InlineData("card", TokenizablePaymentMethodType.Card)]
[InlineData("payPal", TokenizablePaymentMethodType.PayPal)]
public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsTokenized);
Assert.Equal(expectedType, result.AsT0.Type);
Assert.Equal("test-token", result.AsT0.Token);
}
// Non-tokenized payment method deserialization
[Theory]
[InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)]
public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsNonTokenized);
Assert.Equal(expectedType, result.AsT1.Type);
}
// Tokenized payment method serialization
[Theory]
[InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")]
[InlineData(TokenizablePaymentMethodType.Card, "card")]
[InlineData(TokenizablePaymentMethodType.PayPal, "paypal")]
public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod
{
Type = type,
Token = "test-token"
});
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.Contains("\"token\":\"test-token\"", json);
}
// Non-tokenized payment method serialization
[Theory]
[InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")]
public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type });
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.DoesNotContain("token", json);
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
@@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
}
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123";
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
// No existing gateway customer ID
user.GatewayCustomerId = null;
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
//Assert
Assert.True(result.IsT3); // Assuming T3 is the Unhandled result
Assert.IsType<BillingException>(result.AsT3.Exception);
// Verify no customer was created or subscription attempted
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
}

View File

@@ -28,6 +28,9 @@
<None Remove="Utilities\data\embeddedResource.txt" />
</ItemGroup>
<ItemGroup>
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class HandlebarMailRendererTests
{
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
}

View File

@@ -0,0 +1,37 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Test.Platform.Mailer.TestMail;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mail = new TestMail.TestMail()
{
ToEmails = ["test@bw.com"],
View = new TestMailView() { Name = "John Smith" }
};
MailMessage? sentMessage = null;
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
sentMessage = message
));
await mailer.SendEmail(mail);
Assert.NotNull(sentMessage);
Assert.Contains("test@bw.com", sentMessage.ToEmails);
Assert.Equal("Test Email", sentMessage.Subject);
Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim());
Assert.Equivalent("Hello <b>John Smith</b>", sentMessage.HtmlContent.Trim());
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Platform.Mailer;
namespace Bit.Core.Test.Platform.Mailer.TestMail;
public class TestMailView : BaseMailView
{
public required string Name { get; init; }
}
public class TestMail : BaseMail<TestMailView>
{
public override string Subject { get; } = "Test Email";
}

View File

@@ -0,0 +1 @@
Hello <b>{{ Name }}</b>

View File

@@ -0,0 +1 @@
Hello {{ Name }}

View File

@@ -49,6 +49,75 @@ public class OrganizationReportRepositoryTests
Assert.True(records.Count == 4);
}
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task CreateAsync_ShouldPersistAllMetricProperties_WhenSet(
List<EntityFramework.Dirt.Repositories.OrganizationReportRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
OrganizationReportRepository sqlOrganizationReportRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
// Arrange - Create a report with explicit metric values
var fixture = new Fixture();
var organization = fixture.Create<Organization>();
var report = fixture.Build<OrganizationReport>()
.With(x => x.ApplicationCount, 10)
.With(x => x.ApplicationAtRiskCount, 3)
.With(x => x.CriticalApplicationCount, 5)
.With(x => x.CriticalApplicationAtRiskCount, 2)
.With(x => x.MemberCount, 25)
.With(x => x.MemberAtRiskCount, 7)
.With(x => x.CriticalMemberCount, 12)
.With(x => x.CriticalMemberAtRiskCount, 4)
.With(x => x.PasswordCount, 100)
.With(x => x.PasswordAtRiskCount, 15)
.With(x => x.CriticalPasswordCount, 50)
.With(x => x.CriticalPasswordAtRiskCount, 8)
.Create();
var retrievedReports = new List<OrganizationReport>();
// Act & Assert - Test EF repositories
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
var efOrganization = await efOrganizationRepos[i].CreateAsync(organization);
sut.ClearChangeTracking();
report.OrganizationId = efOrganization.Id;
var createdReport = await sut.CreateAsync(report);
sut.ClearChangeTracking();
var savedReport = await sut.GetByIdAsync(createdReport.Id);
retrievedReports.Add(savedReport);
}
// Act & Assert - Test SQL repository
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization);
report.OrganizationId = sqlOrganization.Id;
var sqlCreatedReport = await sqlOrganizationReportRepo.CreateAsync(report);
var savedSqlReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlCreatedReport.Id);
retrievedReports.Add(savedSqlReport);
// Assert - Verify all metric properties are persisted correctly across all repositories
Assert.True(retrievedReports.Count == 4);
foreach (var retrievedReport in retrievedReports)
{
Assert.NotNull(retrievedReport);
Assert.Equal(10, retrievedReport.ApplicationCount);
Assert.Equal(3, retrievedReport.ApplicationAtRiskCount);
Assert.Equal(5, retrievedReport.CriticalApplicationCount);
Assert.Equal(2, retrievedReport.CriticalApplicationAtRiskCount);
Assert.Equal(25, retrievedReport.MemberCount);
Assert.Equal(7, retrievedReport.MemberAtRiskCount);
Assert.Equal(12, retrievedReport.CriticalMemberCount);
Assert.Equal(4, retrievedReport.CriticalMemberAtRiskCount);
Assert.Equal(100, retrievedReport.PasswordCount);
Assert.Equal(15, retrievedReport.PasswordAtRiskCount);
Assert.Equal(50, retrievedReport.CriticalPasswordCount);
Assert.Equal(8, retrievedReport.CriticalPasswordAtRiskCount);
}
}
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task RetrieveByOrganisation_Works(
OrganizationReportRepository sqlOrganizationReportRepo,
@@ -66,6 +135,67 @@ public class OrganizationReportRepositoryTests
Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId);
}
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task UpdateAsync_ShouldUpdateAllMetricProperties_WhenChanged(
OrganizationReportRepository sqlOrganizationReportRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
// Arrange - Create initial report with specific metric values
var fixture = new Fixture();
var organization = fixture.Create<Organization>();
var org = await sqlOrganizationRepo.CreateAsync(organization);
var report = fixture.Build<OrganizationReport>()
.With(x => x.OrganizationId, org.Id)
.With(x => x.ApplicationCount, 10)
.With(x => x.ApplicationAtRiskCount, 3)
.With(x => x.CriticalApplicationCount, 5)
.With(x => x.CriticalApplicationAtRiskCount, 2)
.With(x => x.MemberCount, 25)
.With(x => x.MemberAtRiskCount, 7)
.With(x => x.CriticalMemberCount, 12)
.With(x => x.CriticalMemberAtRiskCount, 4)
.With(x => x.PasswordCount, 100)
.With(x => x.PasswordAtRiskCount, 15)
.With(x => x.CriticalPasswordCount, 50)
.With(x => x.CriticalPasswordAtRiskCount, 8)
.Create();
var createdReport = await sqlOrganizationReportRepo.CreateAsync(report);
// Act - Update all metric properties with new values
createdReport.ApplicationCount = 20;
createdReport.ApplicationAtRiskCount = 6;
createdReport.CriticalApplicationCount = 10;
createdReport.CriticalApplicationAtRiskCount = 4;
createdReport.MemberCount = 50;
createdReport.MemberAtRiskCount = 14;
createdReport.CriticalMemberCount = 24;
createdReport.CriticalMemberAtRiskCount = 8;
createdReport.PasswordCount = 200;
createdReport.PasswordAtRiskCount = 30;
createdReport.CriticalPasswordCount = 100;
createdReport.CriticalPasswordAtRiskCount = 16;
await sqlOrganizationReportRepo.UpsertAsync(createdReport);
// Assert - Verify all metric properties were updated correctly
var updatedReport = await sqlOrganizationReportRepo.GetByIdAsync(createdReport.Id);
Assert.NotNull(updatedReport);
Assert.Equal(20, updatedReport.ApplicationCount);
Assert.Equal(6, updatedReport.ApplicationAtRiskCount);
Assert.Equal(10, updatedReport.CriticalApplicationCount);
Assert.Equal(4, updatedReport.CriticalApplicationAtRiskCount);
Assert.Equal(50, updatedReport.MemberCount);
Assert.Equal(14, updatedReport.MemberAtRiskCount);
Assert.Equal(24, updatedReport.CriticalMemberCount);
Assert.Equal(8, updatedReport.CriticalMemberAtRiskCount);
Assert.Equal(200, updatedReport.PasswordCount);
Assert.Equal(30, updatedReport.PasswordAtRiskCount);
Assert.Equal(100, updatedReport.CriticalPasswordCount);
Assert.Equal(16, updatedReport.CriticalPasswordAtRiskCount);
}
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task Delete_Works(
List<EntityFramework.Dirt.Repositories.OrganizationReportRepository> suts,

View File

@@ -33,14 +33,69 @@ public static class OrganizationTestHelpers
public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository,
int? seatCount = null,
string identifier = "test")
=> organizationRepository.CreateAsync(new Organization
{
var id = Guid.NewGuid();
return organizationRepository.CreateAsync(new Organization
{
Name = $"{identifier}-{Guid.NewGuid()}",
BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl
Name = $"{identifier}-{id}",
BillingEmail = $"billing-{id}@example.com",
Plan = "Enterprise (Annually)",
PlanType = PlanType.EnterpriseAnnually,
Seats = seatCount
Identifier = $"{identifier}-{id}",
BusinessName = $"Test Business {id}",
BusinessAddress1 = "123 Test Street",
BusinessAddress2 = "Suite 100",
BusinessAddress3 = "Building A",
BusinessCountry = "US",
BusinessTaxNumber = "123456789",
Seats = seatCount,
MaxCollections = 50,
UsePolicies = true,
UseSso = true,
UseKeyConnector = true,
UseScim = true,
UseGroups = true,
UseDirectory = true,
UseEvents = true,
UseTotp = true,
Use2fa = true,
UseApi = true,
UseResetPassword = true,
UseSecretsManager = true,
UsePasswordManager = true,
SelfHost = false,
UsersGetPremium = true,
UseCustomPermissions = true,
Storage = 1073741824, // 1 GB in bytes
MaxStorageGb = 10,
Gateway = GatewayType.Stripe,
GatewayCustomerId = $"cus_{id}",
GatewaySubscriptionId = $"sub_{id}",
ReferenceData = "{\"test\":\"data\"}",
Enabled = true,
LicenseKey = $"license-{id}",
PublicKey = "test-public-key",
PrivateKey = "test-private-key",
TwoFactorProviders = null,
ExpirationDate = DateTime.UtcNow.AddYears(1),
MaxAutoscaleSeats = 200,
OwnersNotifiedOfAutoscaling = null,
Status = OrganizationStatusType.Managed,
SmSeats = 50,
SmServiceAccounts = 25,
MaxAutoscaleSmSeats = 100,
MaxAutoscaleSmServiceAccounts = 50,
LimitCollectionCreation = true,
LimitCollectionDeletion = true,
LimitItemDeletion = true,
AllowAdminAccessToAllCollectionItems = true,
UseRiskInsights = true,
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = true,
SyncSeats = false,
UseAutomaticUserConfirmation = true
});
}
/// <summary>
/// Creates a confirmed Owner for the specified organization and user.

View File

@@ -461,13 +461,7 @@ public class OrganizationUserRepositoryTests
KdfParallelism = 3
});
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
PrivateKey = "privatekey",
});
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
@@ -536,9 +530,72 @@ public class OrganizationUserRepositoryTests
Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
}
[Theory, DatabaseData]
public async Task GetManyDetailsByUserAsync_ShouldPopulateSsoPropertiesCorrectly(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ISsoConfigRepository ssoConfigRepository)
{
var user = await userRepository.CreateTestUserAsync();
var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync();
var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync();
var orgUserWithSso = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organizationWithSso.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
Email = user.Email
});
var orgUserWithoutSso = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organizationWithoutSso.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = user.Email
});
// Create SSO configuration for first organization only
var serializedSsoConfigData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize();
var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organizationWithSso.Id,
Enabled = true,
Data = serializedSsoConfigData
});
var results = (await organizationUserRepository.GetManyDetailsByUserAsync(user.Id)).ToList();
Assert.Equal(2, results.Count);
var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id);
var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id);
// Organization with SSO should have SSO properties populated
Assert.True(orgWithSsoDetails.SsoEnabled);
Assert.NotNull(orgWithSsoDetails.SsoConfig);
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
// Organization without SSO should have null SSO properties
Assert.Null(orgWithoutSsoDetails.SsoEnabled);
Assert.Null(orgWithoutSsoDetails.SsoConfig);
}
[DatabaseTheory, DatabaseData]

View File

@@ -0,0 +1,142 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
public class ProviderUserRepositoryTests
{
[Theory, DatabaseData]
public async Task GetManyOrganizationDetailsByUserAsync_ShouldPopulatePropertiesCorrectly(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository,
ISsoConfigRepository ssoConfigRepository)
{
var user = await userRepository.CreateTestUserAsync();
var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync();
var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var providerOrganizationWithSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organizationWithSso.Id
});
var providerOrganizationWithoutSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organizationWithoutSso.Id
});
// Create SSO configuration for first organization only
var serializedSsoConfigData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize();
var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organizationWithSso.Id,
Enabled = true,
Data = serializedSsoConfigData
});
var results = (await providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed)).ToList();
Assert.Equal(2, results.Count);
var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id);
var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id);
// Verify all properties for both organizations
AssertProviderOrganizationDetails(orgWithSsoDetails, organizationWithSso, user, provider, providerUser);
AssertProviderOrganizationDetails(orgWithoutSsoDetails, organizationWithoutSso, user, provider, providerUser);
// Organization without SSO should have null SSO properties
Assert.Null(orgWithoutSsoDetails.SsoEnabled);
Assert.Null(orgWithoutSsoDetails.SsoConfig);
// Organization with SSO should have SSO properties populated
Assert.True(orgWithSsoDetails.SsoEnabled);
Assert.NotNull(orgWithSsoDetails.SsoConfig);
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
}
private static void AssertProviderOrganizationDetails(
ProviderUserOrganizationDetails actual,
Organization expectedOrganization,
User expectedUser,
Provider expectedProvider,
ProviderUser expectedProviderUser)
{
// Organization properties
Assert.Equal(expectedOrganization.Id, actual.OrganizationId);
Assert.Equal(expectedUser.Id, actual.UserId);
Assert.Equal(expectedOrganization.Name, actual.Name);
Assert.Equal(expectedOrganization.UsePolicies, actual.UsePolicies);
Assert.Equal(expectedOrganization.UseSso, actual.UseSso);
Assert.Equal(expectedOrganization.UseKeyConnector, actual.UseKeyConnector);
Assert.Equal(expectedOrganization.UseScim, actual.UseScim);
Assert.Equal(expectedOrganization.UseGroups, actual.UseGroups);
Assert.Equal(expectedOrganization.UseDirectory, actual.UseDirectory);
Assert.Equal(expectedOrganization.UseEvents, actual.UseEvents);
Assert.Equal(expectedOrganization.UseTotp, actual.UseTotp);
Assert.Equal(expectedOrganization.Use2fa, actual.Use2fa);
Assert.Equal(expectedOrganization.UseApi, actual.UseApi);
Assert.Equal(expectedOrganization.UseResetPassword, actual.UseResetPassword);
Assert.Equal(expectedOrganization.UsersGetPremium, actual.UsersGetPremium);
Assert.Equal(expectedOrganization.UseCustomPermissions, actual.UseCustomPermissions);
Assert.Equal(expectedOrganization.SelfHost, actual.SelfHost);
Assert.Equal(expectedOrganization.Seats, actual.Seats);
Assert.Equal(expectedOrganization.MaxCollections, actual.MaxCollections);
Assert.Equal(expectedOrganization.MaxStorageGb, actual.MaxStorageGb);
Assert.Equal(expectedOrganization.Identifier, actual.Identifier);
Assert.Equal(expectedOrganization.PublicKey, actual.PublicKey);
Assert.Equal(expectedOrganization.PrivateKey, actual.PrivateKey);
Assert.Equal(expectedOrganization.Enabled, actual.Enabled);
Assert.Equal(expectedOrganization.PlanType, actual.PlanType);
Assert.Equal(expectedOrganization.LimitCollectionCreation, actual.LimitCollectionCreation);
Assert.Equal(expectedOrganization.LimitCollectionDeletion, actual.LimitCollectionDeletion);
Assert.Equal(expectedOrganization.LimitItemDeletion, actual.LimitItemDeletion);
Assert.Equal(expectedOrganization.AllowAdminAccessToAllCollectionItems, actual.AllowAdminAccessToAllCollectionItems);
Assert.Equal(expectedOrganization.UseRiskInsights, actual.UseRiskInsights);
Assert.Equal(expectedOrganization.UseOrganizationDomains, actual.UseOrganizationDomains);
Assert.Equal(expectedOrganization.UseAdminSponsoredFamilies, actual.UseAdminSponsoredFamilies);
Assert.Equal(expectedOrganization.UseAutomaticUserConfirmation, actual.UseAutomaticUserConfirmation);
// Provider-specific properties
Assert.Equal(expectedProvider.Id, actual.ProviderId);
Assert.Equal(expectedProvider.Name, actual.ProviderName);
Assert.Equal(expectedProvider.Type, actual.ProviderType);
Assert.Equal(expectedProviderUser.Id, actual.ProviderUserId);
Assert.Equal(expectedProviderUser.Status, actual.Status);
Assert.Equal(expectedProviderUser.Type, actual.Type);
}
}