1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 13:13:24 +00:00

Merge branch 'main' into billing/pm-28662/Individual-Premium-automatically-disabled-due-to-duplicate-subscription

This commit is contained in:
cyprain-okeke
2025-12-04 11:47:24 +01:00
committed by GitHub
116 changed files with 15122 additions and 794 deletions

View File

@@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Authorization;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
@@ -14,8 +13,6 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
@@ -32,12 +29,6 @@ public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture<Ap
public OrganizationUsersControllerPutResetPasswordTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

View File

@@ -0,0 +1,289 @@
using System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.SecretsManager.Enums;
using Bit.Api.IntegrationTest.SecretsManager.Helpers;
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Xunit;
namespace Bit.Api.IntegrationTest.SecretsManager.Controllers;
public class SecretVersionsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly string _mockEncryptedString =
"2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly ISecretRepository _secretRepository;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly LoginHelper _loginHelper;
private string _email = null!;
private SecretsManagerOrganizationHelper _organizationHelper = null!;
public SecretVersionsControllerTests(ApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
_secretRepository = _factory.GetService<ISecretRepository>();
_secretVersionRepository = _factory.GetService<ISecretVersionRepository>();
_accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_email);
_organizationHelper = new SecretsManagerOrganizationHelper(_factory, _email);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(false, true, true)]
[InlineData(true, false, false)]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
public async Task GetVersionsBySecretId_SmAccessDenied_NotFound(bool useSecrets, bool accessSecrets, bool organizationEnabled)
{
var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Theory]
[InlineData(PermissionType.RunAsAdmin)]
[InlineData(PermissionType.RunAsUserWithPermission)]
public async Task GetVersionsBySecretId_Success(PermissionType permissionType)
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
// Create some versions
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-1)
});
if (permissionType == PermissionType.RunAsUserWithPermission)
{
var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true);
await _loginHelper.LoginAsync(email);
var accessPolicies = new List<BaseAccessPolicy>
{
new UserSecretAccessPolicy
{
GrantedSecretId = secret.Id,
OrganizationUserId = orgUser.Id,
Read = true,
Write = true
}
};
await _accessPolicyRepository.CreateManyAsync(accessPolicies);
}
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
Assert.NotNull(result);
Assert.Equal(2, result.Data.Count());
}
[Fact]
public async Task GetVersionById_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow
});
var response = await _client.GetAsync($"/secret-versions/{version.Id}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SecretVersionResponseModel>();
Assert.NotNull(result);
Assert.Equal(version.Id, result.Id);
Assert.Equal(secret.Id, result.SecretId);
}
[Fact]
public async Task RestoreVersion_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = "OriginalValue",
Note = _mockEncryptedString
});
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "OldValue",
VersionDate = DateTime.UtcNow.AddDays(-1)
});
var request = new RestoreSecretVersionRequestModel
{
VersionId = version.Id
};
var response = await _client.PutAsJsonAsync($"/secrets/{secret.Id}/versions/restore", request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SecretResponseModel>();
Assert.NotNull(result);
Assert.Equal("OldValue", result.Value);
}
[Fact]
public async Task BulkDelete_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-1)
});
var ids = new List<Guid> { version1.Id, version2.Id };
var response = await _client.PostAsJsonAsync("/secret-versions/delete", ids);
response.EnsureSuccessStatusCode();
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secret.Id);
Assert.Empty(versions);
}
[Fact]
public async Task GetVersionsBySecretId_ReturnsOrderedByVersionDate()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
// Create versions in random order
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version2",
VersionDate = DateTime.UtcNow.AddDays(-1)
});
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version3",
VersionDate = DateTime.UtcNow
});
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version1",
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
Assert.NotNull(result);
Assert.Equal(3, result.Data.Count());
var versions = result.Data.ToList();
// Should be ordered by VersionDate descending (newest first)
Assert.Equal("Version3", versions[0].Value);
Assert.Equal("Version2", versions[1].Value);
Assert.Equal("Version1", versions[2].Value);
}
}

View File

@@ -452,60 +452,10 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath(
public async Task PutResetPassword_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IUserService>().Received(1)
.AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(false);
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization>());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Microsoft.AspNetCore.Http.HttpResults.NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns((OrganizationUser)null);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
@@ -515,12 +465,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound(
public async Task PutResetPassword_WhenOrganizationIdMismatch_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
@@ -530,12 +479,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest(
public async Task PutResetPassword_WhenAuthorizationFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
@@ -551,12 +499,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk(
public async Task PutResetPassword_WhenRecoverAccountSucceeds_ReturnsOk(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
@@ -577,12 +524,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest(
public async Task PutResetPassword_WhenRecoverAccountFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(

View File

@@ -48,6 +48,7 @@ public class ProfileOrganizationResponseModelTests
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UsePhishingBlocker = organization.UsePhishingBlocker,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,

View File

@@ -45,6 +45,7 @@ public class ProfileProviderOrganizationResponseModelTests
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UsePhishingBlocker = organization.UsePhishingBlocker,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,

View File

@@ -1,14 +1,11 @@
using Bit.Api.AdminConsole.Public.Controllers;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -22,7 +19,7 @@ public class PoliciesControllerTests
{
[Theory]
[BitAutoData]
public async Task Put_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task Put_UsesVNextSavePolicyCommand(
Guid organizationId,
PolicyType policyType,
PolicyUpdateRequestModel model,
@@ -33,9 +30,6 @@ public class PoliciesControllerTests
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.OrganizationId.Returns(organizationId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
sutProvider.GetDependency<IVNextSavePolicyCommand>()
.SaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
@@ -52,36 +46,4 @@ public class PoliciesControllerTests
m.PolicyUpdate.Enabled == model.Enabled.GetValueOrDefault() &&
m.PerformedBy is SystemUser));
}
[Theory]
[BitAutoData]
public async Task Put_WhenPolicyValidatorsRefactorDisabled_UsesLegacySavePolicyCommand(
Guid organizationId,
PolicyType policyType,
PolicyUpdateRequestModel model,
Policy policy,
SutProvider<PoliciesController> sutProvider)
{
// Arrange
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.OrganizationId.Returns(organizationId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(false);
sutProvider.GetDependency<ISavePolicyCommand>()
.SaveAsync(Arg.Any<PolicyUpdate>())
.Returns(policy);
// Act
await sutProvider.Sut.Put(policyType, model);
// Assert
await sutProvider.GetDependency<ISavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<PolicyUpdate>(p =>
p.OrganizationId == organizationId &&
p.Type == policyType &&
p.Enabled == model.Enabled));
}
}

View File

@@ -3,7 +3,6 @@ using System.Text.Json;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -291,7 +290,7 @@ public class PoliciesControllerTests
string token,
string email,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -302,14 +301,15 @@ public class PoliciesControllerTests
var decryptedToken = Substitute.For<OrgUserInviteTokenable>();
decryptedToken.Valid.Returns(false);
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
@@ -325,7 +325,7 @@ public class PoliciesControllerTests
string token,
string email,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -338,14 +338,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUserId)
@@ -366,7 +367,7 @@ public class PoliciesControllerTests
string email,
OrganizationUser orgUser,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -379,14 +380,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
orgUser.OrganizationId = Guid.Empty;
@@ -409,7 +411,7 @@ public class PoliciesControllerTests
string email,
OrganizationUser orgUser,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -422,14 +424,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
orgUser.OrganizationId = orgId;
sutProvider.GetDependency<IOrganizationUserRepository>()
@@ -463,7 +466,7 @@ public class PoliciesControllerTests
[Theory]
[BitAutoData]
public async Task PutVNext_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task PutVNext_UsesVNextSavePolicyCommand(
SutProvider<PoliciesController> sutProvider, Guid orgId,
SavePolicyRequest model, Policy policy, Guid userId)
{
@@ -478,10 +481,6 @@ public class PoliciesControllerTests
.OrganizationOwner(orgId)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
sutProvider.GetDependency<IVNextSavePolicyCommand>()
.SaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
@@ -492,12 +491,11 @@ public class PoliciesControllerTests
// Assert
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<SavePolicyModel>(
m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
.SaveAsync(Arg.Is<SavePolicyModel>(m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
await sutProvider.GetDependency<ISavePolicyCommand>()
.DidNotReceiveWithAnyArgs()
@@ -507,51 +505,4 @@ public class PoliciesControllerTests
Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type);
}
[Theory]
[BitAutoData]
public async Task PutVNext_WhenPolicyValidatorsRefactorDisabled_UsesSavePolicyCommand(
SutProvider<PoliciesController> sutProvider, Guid orgId,
SavePolicyRequest model, Policy policy, Guid userId)
{
// Arrange
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(userId);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(orgId)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(false);
sutProvider.GetDependency<ISavePolicyCommand>()
.VNextSaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
// Act
var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model);
// Assert
await sutProvider.GetDependency<ISavePolicyCommand>()
.Received(1)
.VNextSaveAsync(Arg.Is<SavePolicyModel>(
m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.DidNotReceiveWithAnyArgs()
.SaveAsync(default);
Assert.NotNull(result);
Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type);
}
}

View File

@@ -0,0 +1,292 @@
using System.Net;
using System.Reflection;
using Bit.Api.Dirt.Controllers;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Api.Test.Dirt;
[ControllerCustomize(typeof(HibpController))]
[SutProviderCustomize]
public class HibpControllerTests : IDisposable
{
private readonly HttpClient _originalHttpClient;
private readonly FieldInfo _httpClientField;
public HibpControllerTests()
{
// Store original HttpClient for restoration
_httpClientField = typeof(HibpController).GetField("_httpClient", BindingFlags.Static | BindingFlags.NonPublic);
_originalHttpClient = (HttpClient)_httpClientField?.GetValue(null);
}
public void Dispose()
{
// Restore original HttpClient after tests
_httpClientField?.SetValue(null, _originalHttpClient);
}
[Theory, BitAutoData]
public async Task Get_WithMissingApiKey_ThrowsBadRequestException(
SutProvider<HibpController> sutProvider,
string username)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = null;
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.Get(username));
Assert.Equal("HaveIBeenPwned API key not set.", exception.Message);
}
[Theory, BitAutoData]
public async Task Get_WithValidApiKeyAndNoBreaches_Returns200WithEmptyArray(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
var user = new User { Id = userId };
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
// Mock HttpClient to return 404 (no breaches found)
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.NotFound, "");
_httpClientField.SetValue(null, mockHttpClient);
// Act
var result = await sutProvider.Sut.Get(username);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("[]", contentResult.Content);
Assert.Equal("application/json", contentResult.ContentType);
}
[Theory, BitAutoData]
public async Task Get_WithValidApiKeyAndBreachesFound_Returns200WithBreachData(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
var breachData = "[{\"Name\":\"Adobe\",\"Title\":\"Adobe\",\"Domain\":\"adobe.com\"}]";
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.OK, breachData);
_httpClientField.SetValue(null, mockHttpClient);
// Act
var result = await sutProvider.Sut.Get(username);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal(breachData, contentResult.Content);
Assert.Equal("application/json", contentResult.ContentType);
}
[Theory, BitAutoData]
public async Task Get_WithRateLimiting_RetriesWithDelay(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
// First response is rate limited, second is success
var requestCount = 0;
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
{
requestCount++;
if (requestCount == 1)
{
var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
response.Headers.Add("retry-after", "1");
return Task.FromResult(response);
}
else
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("")
});
}
});
var mockHttpClient = new HttpClient(mockHandler);
_httpClientField.SetValue(null, mockHttpClient);
// Act
var result = await sutProvider.Sut.Get(username);
// Assert
Assert.Equal(2, requestCount); // Verify retry happened
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("[]", contentResult.Content);
}
[Theory, BitAutoData]
public async Task Get_WithServerError_ThrowsBadRequestException(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.InternalServerError, "");
_httpClientField.SetValue(null, mockHttpClient);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.Get(username));
Assert.Contains("Request failed. Status code:", exception.Message);
}
[Theory, BitAutoData]
public async Task Get_WithBadRequest_ThrowsBadRequestException(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
var mockHttpClient = CreateMockHttpClient(HttpStatusCode.BadRequest, "");
_httpClientField.SetValue(null, mockHttpClient);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.Get(username));
Assert.Contains("Request failed. Status code:", exception.Message);
}
[Theory, BitAutoData]
public async Task Get_EncodesUsernameCorrectly(
SutProvider<HibpController> sutProvider,
Guid userId)
{
// Arrange
var usernameWithSpecialChars = "test+user@example.com";
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
string capturedUrl = null;
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
{
capturedUrl = request.RequestUri.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("")
});
});
var mockHttpClient = new HttpClient(mockHandler);
_httpClientField.SetValue(null, mockHttpClient);
// Act
await sutProvider.Sut.Get(usernameWithSpecialChars);
// Assert
Assert.NotNull(capturedUrl);
// Username should be URL encoded (+ becomes %2B, @ becomes %40)
Assert.Contains("test%2Buser%40example.com", capturedUrl);
}
[Theory, BitAutoData]
public async Task SendAsync_IncludesRequiredHeaders(
SutProvider<HibpController> sutProvider,
string username,
Guid userId)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().HibpApiKey = "test-api-key";
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>())
.Returns(userId);
HttpRequestMessage capturedRequest = null;
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
{
capturedRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("")
});
});
var mockHttpClient = new HttpClient(mockHandler);
_httpClientField.SetValue(null, mockHttpClient);
// Act
await sutProvider.Sut.Get(username);
// Assert
Assert.NotNull(capturedRequest);
Assert.True(capturedRequest.Headers.Contains("hibp-api-key"));
Assert.True(capturedRequest.Headers.Contains("hibp-client-id"));
Assert.True(capturedRequest.Headers.Contains("User-Agent"));
Assert.Equal("Bitwarden", capturedRequest.Headers.GetValues("User-Agent").First());
}
/// <summary>
/// Helper to create a mock HttpClient that returns a specific status code and content
/// </summary>
private HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string content)
{
var mockHandler = new MockHttpMessageHandler((request, cancellationToken) =>
{
return Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
});
});
return new HttpClient(mockHandler);
}
}
/// <summary>
/// Mock HttpMessageHandler for testing HttpClient behavior
/// </summary>
public class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;
public MockHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
{
_sendAsync = sendAsync;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return _sendAsync(request, cancellationToken);
}
}

View File

@@ -14,6 +14,7 @@ using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;

View File

@@ -1,6 +1,6 @@
#nullable enable
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.KeyManagement.Models.Api.Request;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Models.Request;

View File

@@ -0,0 +1,307 @@
using Bit.Api.SecretsManager.Controllers;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.SecretsManager.Controllers;
[ControllerCustomize(typeof(SecretVersionsController))]
[SutProviderCustomize]
[SecretCustomize]
public class SecretVersionsControllerTests
{
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_SecretNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Guid secretId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secretId).Returns((Secret?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secretId));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_NoAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_NoReadAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((false, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_Success(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
List<SecretVersion> versions,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
foreach (var version in versions)
{
version.SecretId = secret.Id;
}
sutProvider.GetDependency<ISecretVersionRepository>().GetManyBySecretIdAsync(secret.Id).Returns(versions);
var result = await sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id);
Assert.Equal(versions.Count, result.Data.Count());
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
.GetManyBySecretIdAsync(Arg.Is(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetById_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Guid versionId)
{
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(versionId).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetByIdAsync(versionId));
}
[Theory]
[BitAutoData]
public async Task GetById_Success(
SutProvider<SecretVersionsController> sutProvider,
SecretVersion version,
Secret secret,
Guid userId)
{
version.SecretId = secret.Id;
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
var result = await sutProvider.Sut.GetByIdAsync(version.Id);
Assert.Equal(version.Id, result.Id);
Assert.Equal(version.SecretId, result.SecretId);
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_NoWriteAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId)
{
version.SecretId = secret.Id;
request.VersionId = version.Id;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
RestoreSecretVersionRequestModel request,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_VersionBelongsToDifferentSecret_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId)
{
version.SecretId = Guid.NewGuid(); // Different secret
request.VersionId = version.Id;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_Success(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId,
OrganizationUser organizationUser)
{
version.SecretId = secret.Id;
request.VersionId = version.Id;
var versionValue = version.Value;
organizationUser.OrganizationId = secret.OrganizationId;
organizationUser.UserId = userId;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(secret.OrganizationId, userId).Returns(organizationUser);
sutProvider.GetDependency<ISecretRepository>().UpdateAsync(Arg.Any<Secret>()).Returns(x => x.Arg<Secret>());
var result = await sutProvider.Sut.RestoreVersionAsync(secret.Id, request);
await sutProvider.GetDependency<ISecretRepository>().Received(1)
.UpdateAsync(Arg.Is<Secret>(s => s.Value == versionValue));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_EmptyIds_Throws(
SutProvider<SecretVersionsController> sutProvider)
{
await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.BulkDeleteAsync(new List<Guid>()));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
List<Guid> ids)
{
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(ids[0]).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.BulkDeleteAsync(ids));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_NoWriteAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
List<SecretVersion> versions,
Secret secret,
Guid userId)
{
var ids = versions.Select(v => v.Id).ToList();
foreach (var version in versions)
{
version.SecretId = secret.Id;
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
}
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<Secret> { secret });
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.BulkDeleteAsync(ids));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_Success(
SutProvider<SecretVersionsController> sutProvider,
List<SecretVersion> versions,
Secret secret,
Guid userId)
{
var ids = versions.Select(v => v.Id).ToList();
foreach (var version in versions)
{
version.SecretId = secret.Id;
}
sutProvider.GetDependency<ISecretVersionRepository>().GetManyByIdsAsync(ids).Returns(versions);
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<Secret> { secret });
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
await sutProvider.Sut.BulkDeleteAsync(ids);
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
.DeleteManyByIdAsync(Arg.Is<IEnumerable<Guid>>(x => x.SequenceEqual(ids)));
}
}

View File

@@ -2,6 +2,7 @@
using Bit.Api.SecretsManager.Controllers;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.Test.SecretsManager.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -244,6 +245,7 @@ public class SecretsControllerTests
{
data = SetupSecretUpdateRequest(data);
SetControllerUser(sutProvider, new Guid());
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).ReturnsForAnyArgs(AuthorizationResult.Success());
@@ -602,6 +604,7 @@ public class SecretsControllerTests
{
data = SetupSecretUpdateRequest(data, true);
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).Returns(AuthorizationResult.Success());

View File

@@ -1790,118 +1790,6 @@ public class CiphersControllerTests
);
}
[Theory, BitAutoData]
public async Task PutShareMany_ArchivedCipher_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = DateTime.UtcNow;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShareMany_ExistingCipherArchived_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
// Request model does not have ArchivedDate (only the existing cipher does)
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = null;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
// The existing cipher from the repository IS archived
var existingCipher = new CipherDetails
{
Id = request.Id!.Value,
UserId = userId,
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData()),
ArchivedDate = DateTime.UtcNow
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICipherRepository>()
.GetManyByUserIdAsync(userId, withOrganizations: false)
.Returns(Task.FromResult((ICollection<CipherDetails>)[existingCipher]));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShare_ArchivedCipher_ThrowsBadRequestException(
Guid cipherId,
Guid organizationId,
User user,
CipherShareRequestModel model,
SutProvider<CiphersController> sutProvider)
{
model.Cipher.OrganizationId = organizationId.ToString();
model.Cipher.EncryptedFor = user.Id;
var cipher = new Cipher
{
Id = cipherId,
UserId = user.Id,
ArchivedDate = DateTime.UtcNow.AddDays(-1),
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData())
};
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(cipher);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShare(cipherId, model)
);
Assert.Equal("Cannot move an archived item to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PostPurge_WhenUserNotFound_ThrowsUnauthorizedAccessException(
SecretVerificationRequestModel model,

View File

@@ -1,7 +1,6 @@
using Bit.Billing.Constants;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
@@ -128,79 +127,6 @@ public class SubscriptionUpdatedHandlerTests
Arg.Is<ITrigger>(t => t.Key.Name == $"cancel-trigger-{subscriptionId}"));
}
[Fact]
public async Task
HandleAsync_UnpaidProviderSubscription_WithManualSuspensionViaMetadata_DisablesProviderAndSchedulesCancellation()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_test123";
var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Active,
Metadata = new Dictionary<string, string>
{
["suspend_provider"] = null // This is the key part - metadata exists, but value is null
}
};
var currentSubscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string>
{
["providerId"] = providerId.ToString(),
["suspend_provider"] = "true" // Now has a value, indicating manual suspension
},
TestClock = null
};
var parsedEvent = new Event
{
Id = "evt_test123",
Type = HandledStripeWebhook.SubscriptionUpdated,
Data = new EventData
{
Object = currentSubscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};
var provider = new Provider { Id = providerId, Enabled = true };
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
// Verify that UpdateSubscription was called with both CancelAt and the new metadata
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
options.Metadata != null &&
options.Metadata.ContainsKey("suspended_provider_via_webhook_at")));
}
[Fact]
public async Task
HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation()
@@ -245,7 +171,6 @@ public class SubscriptionUpdatedHandlerTests
var provider = new Provider { Id = providerId, Enabled = true };
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
@@ -258,13 +183,12 @@ public class SubscriptionUpdatedHandlerTests
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
// Verify that UpdateSubscription was called with CancelAt but WITHOUT suspension metadata
// Verify that UpdateSubscription was called with CancelAt
await _stripeFacade.Received(1).UpdateSubscription(
subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CancelAt.HasValue &&
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
(options.Metadata == null || !options.Metadata.ContainsKey("suspended_provider_via_webhook_at"))));
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1)));
}
[Fact]
@@ -308,9 +232,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
@@ -355,9 +276,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
@@ -403,9 +321,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
_providerRepository.GetByIdAsync(providerId)
.Returns(provider);
@@ -418,48 +333,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task HandleAsync_UnpaidProviderSubscription_WhenFeatureFlagDisabled_DoesNothing()
{
// Arrange
var providerId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
};
var parsedEvent = new Event { Data = new EventData() };
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
}
[Fact]
public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_DoesNothing()
{
@@ -491,9 +364,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
_providerRepository.GetByIdAsync(providerId)
.Returns((Provider)null);
@@ -848,8 +718,6 @@ public class SubscriptionUpdatedHandlerTests
_stripeFacade
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(newSubscription);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -871,9 +739,6 @@ public class SubscriptionUpdatedHandlerTests
.Received(1)
.UpdateSubscription(newSubscription.Id,
Arg.Is<SubscriptionUpdateOptions>(options => options.CancelAtPeriodEnd == false));
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -894,8 +759,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -914,9 +777,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceiveWithAnyArgs()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -937,8 +797,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -957,9 +815,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceiveWithAnyArgs()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -980,8 +835,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -1000,9 +853,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceiveWithAnyArgs()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -1024,8 +874,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -1046,9 +894,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceiveWithAnyArgs()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -1068,8 +913,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -1090,9 +933,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceive()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
@@ -1111,8 +951,6 @@ public class SubscriptionUpdatedHandlerTests
_providerRepository
.GetByIdAsync(Arg.Any<Guid>())
.Returns(provider);
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
.Returns(true);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -1133,9 +971,6 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade
.DidNotReceive()
.UpdateSubscription(Arg.Any<string>());
_featureService
.Received(1)
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent)

View File

@@ -11,7 +11,9 @@ using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
@@ -252,6 +254,9 @@ public class UpcomingInvoiceHandlerTests
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
.Returns(true);
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -259,6 +264,7 @@ public class UpcomingInvoiceHandlerTests
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
await _pricingClient.Received(1).GetAvailablePremiumPlan();
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o =>
@@ -268,11 +274,15 @@ public class UpcomingInvoiceHandlerTests
o.ProrationBehavior == "none"));
// Verify the updated invoice email was sent with correct price
var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<PremiumRenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
email.Subject == "Your Bitwarden Premium renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1006,8 +1016,11 @@ public class UpcomingInvoiceHandlerTests
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
@@ -1033,6 +1046,8 @@ public class UpcomingInvoiceHandlerTests
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
@@ -1042,10 +1057,13 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1465,6 +1483,200 @@ public class UpcomingInvoiceHandlerTests
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone3SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
{
@@ -1529,8 +1741,11 @@ public class UpcomingInvoiceHandlerTests
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
@@ -1556,6 +1771,8 @@ public class UpcomingInvoiceHandlerTests
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
@@ -1565,10 +1782,13 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1635,8 +1855,11 @@ public class UpcomingInvoiceHandlerTests
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
@@ -1662,6 +1885,8 @@ public class UpcomingInvoiceHandlerTests
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
@@ -1671,10 +1896,13 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1748,8 +1976,11 @@ public class UpcomingInvoiceHandlerTests
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
@@ -1777,6 +2008,8 @@ public class UpcomingInvoiceHandlerTests
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
@@ -1786,10 +2019,13 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<Families2019RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1879,6 +2115,12 @@ public class UpcomingInvoiceHandlerTests
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -1957,4 +2199,332 @@ public class UpcomingInvoiceHandlerTests
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
#region Premium Renewal Email Tests
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 30
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<PremiumRenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Bitwarden Premium renewal is updating" &&
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
email.View.DiscountAmount == "30%" &&
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
));
await _mailService.DidNotReceive().SendInvoiceUpcoming(
Arg.Any<IEnumerable<string>>(),
Arg.Any<decimal>(),
Arg.Any<DateTime>(),
Arg.Any<List<string>>(),
Arg.Any<bool>());
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount)
.ThrowsAsync(new StripeException("Stripe API error"));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is StripeException),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
#endregion
}

View File

@@ -2,7 +2,6 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
@@ -183,17 +182,17 @@ public class VerifyOrganizationDomainCommandTests
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<ISavePolicyCommand>()
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<PolicyUpdate>(x => x.Type == PolicyType.SingleOrg &&
x.OrganizationId == domain.OrganizationId &&
x.Enabled &&
.SaveAsync(Arg.Is<SavePolicyModel>(x => x.PolicyUpdate.Type == PolicyType.SingleOrg &&
x.PolicyUpdate.OrganizationId == domain.OrganizationId &&
x.PolicyUpdate.Enabled &&
x.PerformedBy is StandardUser &&
x.PerformedBy.UserId == userId));
}
[Theory, BitAutoData]
public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand(
public async Task UserVerifyOrganizationDomainAsync_UsesVNextSavePolicyCommand(
OrganizationDomain domain, Guid userId, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationDomainRepository>()
@@ -207,10 +206,6 @@ public class VerifyOrganizationDomainCommandTests
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(userId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
@@ -240,9 +235,9 @@ public class VerifyOrganizationDomainCommandTests
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<ISavePolicyCommand>()
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.DidNotReceive()
.SaveAsync(Arg.Any<PolicyUpdate>());
.SaveAsync(Arg.Any<SavePolicyModel>());
}
[Theory, BitAutoData]

View File

@@ -2,7 +2,6 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@@ -14,7 +13,6 @@ using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -342,26 +340,26 @@ public class SsoConfigServiceTests
await sutProvider.Sut.SaveAsync(ssoConfig, organization);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.SingleOrg &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.SingleOrg &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.ResetPassword &&
t.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.ResetPassword &&
t.PolicyUpdate.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.RequireSso &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.RequireSso &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISsoConfigRepository>().ReceivedWithAnyArgs()
@@ -369,7 +367,7 @@ public class SsoConfigServiceTests
}
[Theory, BitAutoData]
public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task SaveAsync_Tde_UsesVNextSavePolicyCommand(
SutProvider<SsoConfigService> sutProvider, Organization organization)
{
var ssoConfig = new SsoConfig
@@ -383,10 +381,6 @@ public class SsoConfigServiceTests
OrganizationId = organization.Id,
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
await sutProvider.Sut.SaveAsync(ssoConfig, organization);
await sutProvider.GetDependency<IVNextSavePolicyCommand>()

View File

@@ -213,7 +213,8 @@ If you believe you need to change the version for a valid reason, please discuss
LimitCollectionDeletion = true,
AllowAdminAccessToAllCollectionItems = true,
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = false
UseAdminSponsoredFamilies = false,
UsePhishingBlocker = false,
};
}

View File

@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation") &&
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker") &&
// Same property but different name, use explicit mapping
org.ExpirationDate == license.Expires));
}

View File

@@ -520,10 +520,6 @@ public class IdentityServerSsoTests
var keyConnectorUrl = AssertHelper.AssertJsonProperty(keyConnectorOption, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
// For backwards compatibility reasons the url should also be on the root
keyConnectorUrl = AssertHelper.AssertJsonProperty(root, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
}
private static async Task<JsonDocument> RunSuccessTestAsync(MemberDecryptionType memberDecryptionType)

View File

@@ -93,7 +93,8 @@ public static class OrganizationTestHelpers
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = true,
SyncSeats = false,
UseAutomaticUserConfirmation = true
UseAutomaticUserConfirmation = true,
UsePhishingBlocker = true,
});
}

View File

@@ -673,7 +673,8 @@ public class OrganizationUserRepositoryTests
LimitItemDeletion = false,
AllowAdminAccessToAllCollectionItems = false,
UseRiskInsights = false,
UseAdminSponsoredFamilies = false
UseAdminSponsoredFamilies = false,
UsePhishingBlocker = false,
});
var organizationDomain = new OrganizationDomain