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

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick Pimentel
2025-11-17 16:37:47 -05:00
137 changed files with 10715 additions and 873 deletions

View File

@@ -67,7 +67,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
},
Metadata = new Dictionary<string, object>
@@ -148,7 +147,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -218,7 +216,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.MasterPassword;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -244,7 +241,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.SendOptions;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -267,7 +263,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.ResetPassword;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -292,7 +287,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -321,7 +315,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -347,7 +340,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
@@ -371,7 +363,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
var policyType = PolicyType.SingleOrg;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = null
};
@@ -393,7 +384,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = null
},

View File

@@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,

View File

@@ -51,6 +51,36 @@ public class OrganizationIntegrationsConfigurationControllerTests
.DeleteAsync(organizationIntegrationConfiguration);
}
[Theory, BitAutoData]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.GetByIdAsync(organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.DeleteAsync(organizationIntegrationConfiguration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
@@ -199,27 +229,6 @@ public class OrganizationIntegrationsConfigurationControllerTests
.GetManyByIntegrationAsync(organizationIntegration.Id);
}
// [Theory, BitAutoData]
// public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
// SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
// Guid organizationId,
// OrganizationIntegration organizationIntegration)
// {
// organizationIntegration.OrganizationId = organizationId;
// sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
// sutProvider.GetDependency<ICurrentContext>()
// .OrganizationOwner(organizationId)
// .Returns(true);
// sutProvider.GetDependency<IOrganizationIntegrationRepository>()
// .GetByIdAsync(Arg.Any<Guid>())
// .Returns(organizationIntegration);
// sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
// .GetByIdAsync(Arg.Any<Guid>())
// .ReturnsNull();
//
// await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty));
// }
//
[Theory, BitAutoData]
public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
@@ -293,15 +302,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -331,15 +341,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -369,15 +380,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
Assert.Equal(expected.Id, createResponse.Id);
Assert.Equal(expected.Configuration, createResponse.Configuration);
Assert.Equal(expected.EventType, createResponse.EventType);
Assert.Equal(expected.Filters, createResponse.Filters);
Assert.Equal(expected.Template, createResponse.Template);
}
[Theory, BitAutoData]
@@ -575,7 +587,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -583,11 +595,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
@@ -619,7 +632,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -627,11 +640,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
[Theory, BitAutoData]
@@ -662,7 +676,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
var updateResponse = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
@@ -670,11 +684,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
Assert.Equal(expected.Id, updateResponse.Id);
Assert.Equal(expected.Configuration, updateResponse.Configuration);
Assert.Equal(expected.EventType, updateResponse.EventType);
Assert.Equal(expected.Filters, updateResponse.Filters);
Assert.Equal(expected.Template, updateResponse.Template);
}
[Theory, BitAutoData]

View File

@@ -71,6 +71,26 @@ public class SlackIntegrationControllerTests
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Slack;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
@@ -153,6 +173,8 @@ public class SlackIntegrationControllerTests
OrganizationIntegration wrongOrgIntegration)
{
wrongOrgIntegration.Id = integration.Id;
wrongOrgIntegration.Type = IntegrationType.Slack;
wrongOrgIntegration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
@@ -304,6 +326,22 @@ public class SlackIntegrationControllerTests
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_CallbackUrlReturnsEmpty_ThrowsBadRequest(
SutProvider<SlackIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(
SutProvider<SlackIntegrationController> sutProvider,

View File

@@ -60,6 +60,26 @@ public class TeamsIntegrationControllerTests
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
@@ -315,6 +335,30 @@ public class TeamsIntegrationControllerTests
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
integration.Type = IntegrationType.Teams;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns((string?)null);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,

View File

@@ -1,14 +1,47 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModelTests
{
[Fact]
public void ToOrganizationIntegration_CreatesNewOrganizationIntegration()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Hec,
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
};
var organizationId = Guid.NewGuid();
var organizationIntegration = model.ToOrganizationIntegration(organizationId);
Assert.Equal(organizationIntegration.Type, model.Type);
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
Assert.Equal(organizationIntegration.OrganizationId, organizationId);
}
[Theory, BitAutoData]
public void ToOrganizationIntegration_UpdatesExistingOrganizationIntegration(OrganizationIntegration integration)
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Hec,
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
};
var organizationIntegration = model.ToOrganizationIntegration(integration);
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
}
[Fact]
public void Validate_CloudBillingSync_ReturnsNotYetSupportedError()
{

View File

@@ -24,11 +24,11 @@ public class SavePolicyRequestTests
currentContext.OrganizationOwner(organizationId).Returns(true);
var testData = new Dictionary<string, object> { { "test", "value" } };
var policyType = PolicyType.TwoFactorAuthentication;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.TwoFactorAuthentication,
Enabled = true,
Data = testData
},
@@ -36,7 +36,7 @@ public class SavePolicyRequestTests
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type);
@@ -63,17 +63,17 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(false);
var policyType = PolicyType.SingleOrg;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.SingleOrg,
Enabled = false
}
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.Null(result.PolicyUpdate.Data);
@@ -93,17 +93,17 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(true);
var policyType = PolicyType.SingleOrg;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.SingleOrg,
Enabled = false
}
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.Null(result.PolicyUpdate.Data);
@@ -124,11 +124,11 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(true);
var policyType = PolicyType.OrganizationDataOwnership;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true
},
Metadata = new Dictionary<string, object>
@@ -138,7 +138,7 @@ public class SavePolicyRequestTests
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.IsType<OrganizationModelOwnershipPolicyModel>(result.Metadata);
@@ -156,17 +156,17 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(true);
var policyType = PolicyType.OrganizationDataOwnership;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true
}
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.NotNull(result);
@@ -193,12 +193,11 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(true);
var policyType = PolicyType.ResetPassword;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.ResetPassword,
Enabled = true,
Data = _complexData
},
@@ -206,7 +205,7 @@ public class SavePolicyRequestTests
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
var deserializedData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.PolicyUpdate.Data);
@@ -234,11 +233,11 @@ public class SavePolicyRequestTests
currentContext.UserId.Returns(userId);
currentContext.OrganizationOwner(organizationId).Returns(true);
var policyType = PolicyType.MaximumVaultTimeout;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.MaximumVaultTimeout,
Enabled = true
},
Metadata = new Dictionary<string, object>
@@ -248,7 +247,7 @@ public class SavePolicyRequestTests
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.NotNull(result);
@@ -266,19 +265,18 @@ public class SavePolicyRequestTests
currentContext.OrganizationOwner(organizationId).Returns(true);
var errorDictionary = BuildErrorDictionary();
var policyType = PolicyType.OrganizationDataOwnership;
var model = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true
},
Metadata = errorDictionary
};
// Act
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
// Assert
Assert.NotNull(result);

View File

@@ -0,0 +1,800 @@
using System.Security.Claims;
using Bit.Api.Billing.Controllers;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Billing.Controllers;
[SubscriptionInfoCustomize]
public class AccountsControllerTests : IDisposable
{
private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly IPaymentService _paymentService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly GlobalSettings _globalSettings;
private readonly AccountsController _sut;
public AccountsControllerTests()
{
_userService = Substitute.For<IUserService>();
_featureService = Substitute.For<IFeatureService>();
_paymentService = Substitute.For<IPaymentService>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_globalSettings = new GlobalSettings { SelfHosted = false };
_sut = new AccountsController(
_userService,
_twoFactorIsEnabledQuery,
_userAccountKeysQuery,
_featureService
);
}
public void Dispose()
{
_sut?.Dispose();
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user)
{
// Arrange
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license)
{
// Arrange
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when no gateway
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a Stripe Discount object with real structure
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 1400, // 1400 cents = $14.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null // Active discount
};
// Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does)
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify full pipeline conversion
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe data correctly converted to API response
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
// Verify cents-to-dollars conversion (1400 cents -> $14.00)
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
// Verify AppliesTo products are preserved
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility(
User user,
UserLicense license)
{
// Arrange - Create Stripe Discount
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m
},
End = null
};
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act & Assert - Feature flag ENABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.NotNull(resultWithFlag.CustomerDiscount);
// Act & Assert - Feature flag DISABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.Null(resultWithoutFlag.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a real Stripe Discount object as it would come from Stripe API
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families", "prod_teams" }
}
},
End = null // Active discount (no end date)
};
// Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount
// This simulates what StripePaymentService.GetSubscriptionAsync does
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
// Verify the mapping worked correctly
Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id);
Assert.True(billingCustomerDiscount.Active);
Assert.Equal(30m, billingCustomerDiscount.PercentOff);
Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents
Assert.NotNull(billingCustomerDiscount.AppliesTo);
Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count);
// Step 2: Create SubscriptionInfo with the mapped discount
// This simulates what StripePaymentService returns
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
// Step 3: Set up controller dependencies
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Step 4: Call AccountsController.GetSubscriptionAsync
// This exercises the complete pipeline:
// - Retrieves subscriptionInfo from paymentService (with discount from Stripe)
// - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above)
// - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status)
// - Returns via AccountsController
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify the complete pipeline worked end-to-end
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping
// (verified above, but confirming it made it through)
// Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering
// The filter should pass because:
// - includeMilestone2Discount = true (feature flag enabled)
// - subscription.CustomerDiscount != null
// - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount
// - subscription.CustomerDiscount.Active = true
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion
// Verify AppliesTo products are preserved through the entire pipeline
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo);
// Verify the payment service was called correctly
await _paymentService.Received(1).GetSubscriptionAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with multiple discounts
// Customer discount should be preferred over subscription discounts
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = null
},
End = null
};
var subscriptionDiscount1 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-1",
PercentOff = 10m
},
End = null
};
var subscriptionDiscount2 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-2",
PercentOff = 15m
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should use customer discount, not subscription discounts
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff
// This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium" }
}
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Both values should be preserved through the pipeline
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with subscription details
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
TrialStart = DateTime.UtcNow.AddDays(-30),
TrialEnd = DateTime.UtcNow.AddDays(-20),
CanceledAt = null,
CancelAtPeriodEnd = false,
CollectionMethod = "charge_automatically"
};
// Map through SubscriptionInfo.BillingSubscription
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var subscriptionInfo = new SubscriptionInfo
{
Subscription = billingSubscription,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingSubscription mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe invoice for upcoming invoice
var stripeInvoice = new Invoice
{
AmountDue = 2000, // 2000 cents = $20.00
Created = DateTime.UtcNow.AddDays(1)
};
// Map through SubscriptionInfo.BillingUpcomingInvoice
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
UpcomingInvoice = billingUpcomingInvoice,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingUpcomingInvoice mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents(
User user,
UserLicense license)
{
// Arrange - Complete Stripe objects for full pipeline test
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m,
AmountOff = 1000, // $10.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null
};
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // $15.00
Created = DateTime.UtcNow.AddDays(7)
};
// Map through SubscriptionInfo (simulating StripePaymentService)
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount,
Subscription = billingSubscription,
UpcomingInvoice = billingUpcomingInvoice
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify all components mapped correctly through the pipeline
Assert.NotNull(result);
// Verify discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(10.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
// Verify subscription
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod);
// Verify upcoming invoice
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user)
{
// Arrange - Self-hosted user with discount flag enabled (should still return null)
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert - Should never include discount for self-hosted, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount(
User user,
UserLicense license)
{
// Arrange - User with null gateway and discount flag enabled (should still return null)
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should never include discount when no gateway, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
}

View File

@@ -487,14 +487,14 @@ public class PoliciesControllerTests
.Returns(policy);
// Act
var result = await sutProvider.Sut.PutVNext(orgId, model);
var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model);
// Assert
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<SavePolicyModel>(
m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == model.Policy.Type &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
@@ -534,14 +534,14 @@ public class PoliciesControllerTests
.Returns(policy);
// Act
var result = await sutProvider.Sut.PutVNext(orgId, model);
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 == model.Policy.Type &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));

View File

@@ -0,0 +1,400 @@
using Bit.Api.Models.Response;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Models.Response;
public class SubscriptionResponseModelTests
{
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Null(result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false);
// Assert - Should be null because includeMilestone2Discount is false
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullCustomerDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = null
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = null,
AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount
AppliesTo = new List<string>()
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Null(result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act - Using default parameter (includeMilestone2Discount defaults to false)
var result = new SubscriptionResponseModel(user, subscriptionInfo, license);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = null, // Null discount ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserOnly_SetsBasicProperties(User user)
{
// Arrange
user.Storage = 5368709120; // 5 GB in bytes
user.MaxStorageGb = (short)10;
user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12);
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.NotNull(result.StorageName);
Assert.Equal(5.0, result.StorageGb);
Assert.Equal((short)10, result.MaxStorageGb);
Assert.Equal(user.PremiumExpirationDate, result.Expiration);
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license)
{
// Arrange
user.Storage = 1073741824; // 1 GB in bytes
user.MaxStorageGb = (short)5;
// Act
var result = new SubscriptionResponseModel(user, license);
// Assert
Assert.NotNull(result.License);
Assert.Equal(license, result.License);
Assert.Equal(1.0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullStorage_SetsStorageToZero(User user)
{
// Arrange
user.Storage = null;
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.Null(result.StorageName);
Assert.Equal(0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullLicense_ExcludesLicense(User user)
{
// Act
var result = new SubscriptionResponseModel(user, null);
// Assert
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Both PercentOff and AmountOff present
// This tests the scenario where Stripe coupon has both discount types
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 25m,
AmountOff = 20.00m, // Already converted from cents
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Both values should be preserved
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties(
User user,
UserLicense license)
{
// Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // 1500 cents = $15.00
Created = DateTime.UtcNow.AddDays(7)
};
var subscriptionInfo = new SubscriptionInfo
{
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription),
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice),
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Verify all properties are mapped correctly
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully(
User user,
UserLicense license)
{
// Arrange - Test with null Subscription and UpcomingInvoice
var subscriptionInfo = new SubscriptionInfo
{
Subscription = null,
UpcomingInvoice = null,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Null Subscription and UpcomingInvoice should be handled gracefully
Assert.Null(result.Subscription);
Assert.Null(result.UpcomingInvoice);
Assert.NotNull(result.CustomerDiscount);
}
}

View File

@@ -268,7 +268,9 @@ public class UpcomingInvoiceHandlerTests
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items[0].Id == priceSubscriptionId &&
o.Items[0].Price == priceId));
o.Items[0].Price == priceId &&
o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount &&
o.ProrationBehavior == "none"));
// Verify the updated invoice email was sent
await _mailer.Received(1).SendEmail(
@@ -944,4 +946,847 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<bool>(),
Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization()
{
// 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 premiumAccessItemId = "si_premium_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == premiumAccessItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization()
{
// 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 List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
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 = new List<Subscription> { 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);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Disabled_DoesNotUpdateSubscription()
{
// 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 List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
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 = new List<Subscription> { 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);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription or organization when feature flag is disabled
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_pm_123",
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually // Already on the new plan
};
_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.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription when not on FamiliesAnnually2019 plan
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_different_item",
Price = new Price { Id = "different-price-id" }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should not update subscription or organization when password manager item not found
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError()
{
// 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 List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
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 = new List<Subscription> { 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);
// Simulate update failure
_stripeFacade
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.ThrowsAsync(new Exception("Stripe API error"));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_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.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should still attempt to send email despite the failure
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
{
// 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 seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 3
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == seatAddOnItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_DeletesItem()
{
// 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 seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 1
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == seatAddOnItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddOn_UpdatesBothItems()
{
// 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 premiumAccessItemId = "si_premium_123";
var seatAddOnItemId = "si_seat_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { 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 List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 2
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { 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);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 3 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == premiumAccessItemId &&
o.Items[1].Deleted == true &&
o.Items[2].Id == seatAddOnItemId &&
o.Items[2].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
}

View File

@@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests
Assert.Equal(expected, sut.EventMessage);
}
[Theory, BitAutoData]
public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage)
{
var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc);
eventMessage.Date = testDate;
var sut = new IntegrationTemplateContext(eventMessage);
var result = sut.DateIso8601;
Assert.Equal("2025-10-27T13:30:00.0000000Z", result);
// Verify it's valid ISO 8601
Assert.True(DateTime.TryParse(result, out _));
}
[Theory, BitAutoData]
public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user)
{

View File

@@ -97,6 +97,8 @@ public class ConfirmOrganizationUserCommandTests
[BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
[BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]

View File

@@ -23,6 +23,7 @@ public class CloudICloudOrganizationSignUpCommandTests
{
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2025)]
public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
{
signup.Plan = planType;
@@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2025)]
public async Task SignUp_AssignsOwnerToDefaultCollection
(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
{

View File

@@ -0,0 +1,628 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
[SutProviderCustomize]
public class AutomaticUserConfirmationPolicyEventHandlerTests
{
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns((Policy?)null);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid userId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = userId,
Email = "test@email.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = null, // invited users do not have a user id
Status = OrganizationUserStatusType.Invited,
Email = orgUser.Email
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var providerUser = new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Status = ProviderUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([providerUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = Guid.NewGuid(),
Email = "user@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantOwnerId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var ownerUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantOwnerId,
Email = "owner@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantOwnerId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([ownerUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var invitedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
UserId = Guid.NewGuid(),
Email = "invited@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var revokedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Revoked,
UserId = Guid.NewGuid(),
Email = "revoked@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([revokedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var acceptedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Accepted,
UserId = nonCompliantUserId,
Email = "accepted@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([acceptedUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == true &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == false &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns((Organization?)null);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpsertAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
organization.RevisionDate = originalRevisionDate;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.RevisionDate > originalRevisionDate));
}
}

View File

@@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
}
[Fact]
public async Task CreateManyAsync_EmptyList_DoesNothing()
{
await Subject.CreateManyAsync([]);
await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
public async Task DisposeAsync_DisposesEventIntegrationPublisher()
{
await Subject.DisposeAsync();
await _eventIntegrationPublisher.Received(1).DisposeAsync();
}
private static bool AssertJsonStringsMatch(EventMessage expected, string body)
{
var actual = JsonSerializer.Deserialize<EventMessage>(body);

View File

@@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
eventMessage.OrganizationId = null;
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
{

View File

@@ -1,65 +0,0 @@
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class EventRouteServiceTests
{
private readonly IEventWriteService _broadcastEventWriteService = Substitute.For<IEventWriteService>();
private readonly IEventWriteService _storageEventWriteService = Substitute.For<IEventWriteService>();
private readonly IFeatureService _featureService = Substitute.For<IFeatureService>();
private readonly EventRouteService Subject;
public EventRouteServiceTests()
{
Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService);
}
[Theory, BitAutoData]
public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
await Subject.CreateAsync(eventMessage);
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
await _storageEventWriteService.Received(1).CreateAsync(eventMessage);
}
[Theory, BitAutoData]
public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
await Subject.CreateAsync(eventMessage);
await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage);
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
}
[Theory, BitAutoData]
public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable<EventMessage> eventMessages)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
await Subject.CreateManyAsync(eventMessages);
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages);
}
[Theory, BitAutoData]
public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable<EventMessage> eventMessages)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
await Subject.CreateManyAsync(eventMessages);
await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages);
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
}
}

View File

@@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage)
{
var userId = Guid.NewGuid();
eventMessage.UserId = userId;
var group = new IntegrationFilterGroup
{
AndOperator = true,
Rules =
[
new()
{
Property = "UserId",
Operation = IntegrationFilterOperation.Equals,
Value = userId.ToString()
}
]
};
var result = _service.EvaluateFilterGroup(group, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(group);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage)
{
@@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage)
{
var id = Guid.NewGuid();
var collectionId = Guid.NewGuid();
eventMessage.UserId = id;
eventMessage.CollectionId = collectionId;
var nestedGroup = new IntegrationFilterGroup
{
AndOperator = false,
Rules =
[
new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id },
new()
{
Property = "CollectionId",
Operation = IntegrationFilterOperation.In,
Value = new Guid?[] { Guid.NewGuid() }
}
]
};
var topGroup = new IntegrationFilterGroup
{
AndOperator = false,
Groups = [nestedGroup]
};
var result = _service.EvaluateFilterGroup(topGroup, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(topGroup);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage)
{

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests
var sutProvider = GetSutProvider();
message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token);
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
@@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("service_unavailable")]
[InlineData("ratelimited")]
[InlineData("rate_limited")]
[InlineData("internal_error")]
[InlineData("message_limit_exceeded")]
public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("access_denied")]
[InlineData("channel_not_found")]
[InlineData("token_expired")]
[InlineData("token_revoked")]
public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Fact]
public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure()
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((SlackSendMessageResponse?)null);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal("Slack response was null", result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
}

View File

@@ -146,6 +146,27 @@ public class SlackServiceTests
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult()
{
var emptyResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = Array.Empty<string>(),
response_metadata = new { next_cursor = "" }
});
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(emptyResponse));
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general");
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_ReturnsCorrectChannelId()
{
@@ -235,6 +256,32 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userId = "U12345";
var userResponse = new
{
ok = true,
user = new { id = userId }
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
_handler.When("https://slack.com/api/conversations.open")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("NOT JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString()
{
@@ -244,7 +291,7 @@ public class SlackServiceTests
var userResponse = new
{
ok = false,
error = "An error occured"
error = "An error occurred"
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
@@ -256,6 +303,21 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
@@ -341,18 +403,29 @@ public class SlackServiceTests
}
[Fact]
public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = true,
channel = channelId,
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(string.Empty));
.WithContent(new StringContent(jsonResponse));
await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.True(result.Ok);
// Request was sent correctly
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
@@ -365,4 +438,62 @@ public class SlackServiceTests
Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty);
Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty);
}
[Fact]
public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = false,
channel = channelId,
error = "error"
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.False(result.Ok);
Assert.NotNull(result.Error);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -80,6 +81,120 @@ public class RegisterUserCommandTests
.SendWelcomeEmailAsync(Arg.Any<User>());
}
// -----------------------------------------------------------------------------------------------
// RegisterSSOAutoProvisionedUserAsync tests
// -----------------------------------------------------------------------------------------------
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_Success(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.Id = Guid.NewGuid();
organization.Id = Guid.NewGuid();
organization.Name = "Test Organization";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IUserService>()
.Received(1)
.CreateUserAsync(user);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
var expectedError = new IdentityError();
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Failed(expectedError));
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.False(result.Succeeded);
Assert.Contains(expectedError, result.Errors);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationUserWelcomeEmailAsync(Arg.Any<User>(), Arg.Any<string>());
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Enterprise Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.Name);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendWelcomeEmailAsync(user);
}
// -----------------------------------------------------------------------------------------------
// RegisterUserWithOrganizationInviteToken tests
// -----------------------------------------------------------------------------------------------
@@ -646,5 +761,186 @@ public class RegisterUserCommandTests
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
}
// -----------------------------------------------------------------------------------------------
// SendWelcomeEmail tests
// -----------------------------------------------------------------------------------------------
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.Free)]
public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Family Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail(
User user,
OrganizationUser orgUser,
string orgInviteToken,
string masterPasswordHash,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns((Organization)null);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail(
User user,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
Organization organization = new Organization
{
Name = null
};
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails(
Organization organization,
User user,
OrganizationUser orgUser,
string masterPasswordHash,
string orgInviteToken,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
organization.PlanType = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(orgUser.OrganizationId);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName());
}
}

View File

@@ -21,15 +21,6 @@ namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class GetOrganizationMetadataQueryTests
{
[Theory, BitAutoData]
public async Task Run_NullOrganization_ReturnsNull(
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
var result = await sutProvider.Sut.Run(null);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task Run_SelfHosted_ReturnsDefault(
Organization organization,
@@ -74,8 +65,7 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
@@ -100,12 +90,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
@@ -124,23 +114,24 @@ public class GetOrganizationMetadataQueryTests
organization.PlanType = PlanType.EnterpriseAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -162,12 +153,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -189,13 +180,11 @@ public class GetOrganizationMetadataQueryTests
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.TeamsAnnually;
var customer = new Customer
{
Discount = null
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts = null,
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -217,12 +206,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -244,23 +233,24 @@ public class GetOrganizationMetadataQueryTests
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -282,12 +272,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -310,23 +300,24 @@ public class GetOrganizationMetadataQueryTests
organization.PlanType = PlanType.FamiliesAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -348,12 +339,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()

View File

@@ -53,7 +53,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
@@ -720,4 +720,63 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
const short additionalStorage = 2;
// Setup premium plan with 5GB provided storage
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage);
// Assert
Assert.True(result.IsT0);
Assert.Equal((short)3, user.MaxStorageGb); // 1 (provided) + 2 (additional) = 3
await _userService.Received(1).SaveUserAsync(user);
}
}

View File

@@ -0,0 +1,527 @@
using System.Net;
using Bit.Core.Billing;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using RichardSzalay.MockHttp;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Billing.Pricing;
[SutProviderCustomize]
public class PricingClientTests
{
#region GetLookupKey Tests (via GetPlan)
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_UsesFamilies2025LookupKey()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families-2025", "Families 2025", "families", 40M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families-2025")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_UsesFamiliesLookupKey()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should change "families" to "families-2025" when FF is disabled
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region PreProcessFamiliesPreMigrationPlan Tests (via GetPlan)
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_ReturnsFamiliesAnnually2025PlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
// billing-pricing returns "families" lookup key because the flag is off
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should convert the families lookup key to families-2025
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_ReturnsFamiliesAnnually2025PlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families-2025", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnuallyAndFeatureFlagEnabled_ReturnsFamiliesAnnuallyPlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
// and the PlanAdapter should assign the correct FamiliesAnnually plan type
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithOtherLookupKey_KeepsLookupKeyUnchanged()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/enterprise-annually")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.EnterpriseAnnually);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.EnterpriseAnnually, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region ListPlans Tests
[Fact]
public async Task ListPlans_WithFeatureFlagDisabled_ReturnsListWithPreProcessing()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
// biling-pricing would return "families" because the flag is disabled
var plansJson = $@"[
{CreatePlanJson("families", "Families", "families", 40M, "price_id")},
{CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id")}
]";
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond("application/json", plansJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
// First plan should have been preprocessed from "families" to "families-2025"
Assert.Equal(PlanType.FamiliesAnnually2025, result[0].Type);
// Second plan should remain unchanged
Assert.Equal(PlanType.EnterpriseAnnually, result[1].Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task ListPlans_WithFeatureFlagEnabled_ReturnsListWithoutPreProcessing()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var plansJson = $@"[
{CreatePlanJson("families", "Families", "families", 40M, "price_id")}
]";
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond("application/json", plansJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Single(result);
// Plan should remain as FamiliesAnnually when FF is enabled
Assert.Equal(PlanType.FamiliesAnnually, result[0].Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region GetPlan - Additional Coverage
[Theory, BitAutoData]
public async Task GetPlan_WhenSelfHosted_ReturnsNull(
SutProvider<PricingClient> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
globalSettings.SelfHosted = true;
// Act
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task GetPlan_WhenPricingServiceDisabled_ReturnsStaticStorePlan(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(false);
// Act
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
}
[Theory, BitAutoData]
public async Task GetPlan_WhenLookupKeyNotFound_ReturnsNull(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(true);
// Act - Using PlanType that doesn't have a lookup key mapping
var result = await sutProvider.Sut.GetPlan(unchecked((PlanType)999));
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetPlan_WhenPricingServiceReturnsNotFound_ReturnsNull()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond(HttpStatusCode.NotFound);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetPlan_WhenPricingServiceReturnsError_ThrowsBillingException()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond(HttpStatusCode.InternalServerError);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act & Assert
await Assert.ThrowsAsync<BillingException>(() =>
pricingClient.GetPlan(PlanType.FamiliesAnnually2025));
}
#endregion
#region ListPlans - Additional Coverage
[Theory, BitAutoData]
public async Task ListPlans_WhenSelfHosted_ReturnsEmptyList(
SutProvider<PricingClient> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
globalSettings.SelfHosted = true;
// Act
var result = await sutProvider.Sut.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory, BitAutoData]
public async Task ListPlans_WhenPricingServiceDisabled_ReturnsStaticStorePlans(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(false);
// Act
var result = await sutProvider.Sut.ListPlans();
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.Equal(StaticStore.Plans.Count(), result.Count);
}
[Fact]
public async Task ListPlans_WhenPricingServiceReturnsError_ThrowsBillingException()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond(HttpStatusCode.InternalServerError);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act & Assert
await Assert.ThrowsAsync<BillingException>(() =>
pricingClient.ListPlans());
}
#endregion
private static string CreatePlanJson(
string lookupKey,
string name,
string tier,
decimal seatsPrice,
string seatsStripePriceId,
int seatsQuantity = 1)
{
return $@"{{
""lookupKey"": ""{lookupKey}"",
""name"": ""{name}"",
""tier"": ""{tier}"",
""features"": [],
""seats"": {{
""type"": ""packaged"",
""quantity"": {seatsQuantity},
""price"": {seatsPrice},
""stripePriceId"": ""{seatsStripePriceId}""
}},
""canUpgradeTo"": [],
""additionalData"": {{
""nameLocalizationKey"": ""{lookupKey}Name"",
""descriptionLocalizationKey"": ""{lookupKey}Description""
}}
}}";
}
}

View File

@@ -38,31 +38,32 @@ public class OrganizationBillingServiceTests
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 };
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["product_id"]
}
}
}
};
var customer = new Customer();
subscriberService
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
subscriberService.GetSubscription(organization).Returns(new Subscription
{
Items = new StripeList<SubscriptionItem>
subscriberService.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to"))).Returns(new Subscription
{
Data =
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["product_id"]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
@@ -72,8 +73,8 @@ public class OrganizationBillingServiceTests
}
}
]
}
});
}
});
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
@@ -109,11 +110,12 @@ public class OrganizationBillingServiceTests
// Set up subscriber service to return null for customer
subscriberService
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to"))
.GetCustomer(organization)
.Returns((Customer)null);
// Set up subscriber service to return null for subscription
subscriberService.GetSubscription(organization).Returns((Subscription)null);
subscriberService.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to"))).Returns((Subscription)null);
var metadata = await sutProvider.Sut.GetMetadata(organizationId);

View File

@@ -0,0 +1,497 @@
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class BillingCustomerDiscountTests
{
[Theory]
[BitAutoData]
public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 25.5m,
AmountOff = null,
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "product1", "product2" }
}
},
End = null // Active discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Equal(25.5m, result.PercentOff);
Assert.Null(result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(2, result.AppliesTo.Count);
Assert.Contains("product1", result.AppliesTo);
Assert.Contains("product2", result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId)
{
// Arrange - Stripe sends 1400 cents for $14.00
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1400, // 1400 cents
AppliesTo = new CouponAppliesTo
{
Products = new List<string>()
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Equal(14.00m, result.AmountOff); // Converted to dollars
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 15m
},
End = DateTime.UtcNow.AddDays(-1) // Expired discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.False(result.Active);
Assert.Equal(15m, result.PercentOff);
}
[Fact]
public void Constructor_NullCoupon_SetsDiscountPropertiesToNull()
{
// Arrange
var discount = new Discount
{
Coupon = null,
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AmountOff = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 0
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $100.00 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 10000 // 10000 cents = $100.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(100.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $0.50 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 50 // 50 cents = $0.50
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0.50m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId)
{
// Arrange - Coupon with both percentage and amount (edge case)
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m,
AmountOff = 500 // $5.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(20m, result.PercentOff);
Assert.Equal(5.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId)
{
// Arrange - 1425 cents = $14.25
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 1425
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(14.25m, result.AmountOff);
}
[Fact]
public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse()
{
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount();
// Assert
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount expires in the future
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(30) // Expires in 30 days
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Theory]
[BitAutoData]
public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount already expired
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Fact]
public void Constructor_WithNullCouponId_SetsIdToNull()
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = 20m
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Equal(20m, result.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1000
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.PercentOff);
Assert.Equal(10.00m, result.AmountOff);
}
[Fact]
public void Constructor_WithCompleteStripeDiscount_MapsAllProperties()
{
// Arrange - Comprehensive test with all Stripe Discount properties set
var discount = new Discount
{
Coupon = new Coupon
{
Id = "premium_discount_2024",
PercentOff = 25m,
AmountOff = 1500, // $15.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_family", "prod_teams" }
}
},
End = null // Active
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Verify all properties mapped correctly
Assert.Equal("premium_discount_2024", result.Id);
Assert.True(result.Active);
Assert.Equal(25m, result.PercentOff);
Assert.Equal(15.00m, result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(3, result.AppliesTo.Count);
Assert.Contains("prod_premium", result.AppliesTo);
Assert.Contains("prod_family", result.AppliesTo);
Assert.Contains("prod_teams", result.AppliesTo);
}
[Fact]
public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully()
{
// Arrange - Minimal Stripe Discount with most properties null
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = null,
AmountOff = null,
AppliesTo = null
},
End = DateTime.UtcNow.AddDays(10) // Has end date
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Should handle all nulls gracefully
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = new List<string>() // Empty but not null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
}

View File

@@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests
}
public static TheoryData<Plan> NonSmPlans =>
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]);
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually2019]);
public static TheoryData<Plan> SmPlans => ToPlanTheory([
PlanType.EnterpriseAnnually2019,

View File

@@ -0,0 +1,125 @@
using Bit.Core.Models.Business;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class SubscriptionInfoTests
{
[Fact]
public void BillingSubscriptionItem_NullPlan_HandlesGracefully()
{
// Arrange - SubscriptionItem with null Plan
var subscriptionItem = new SubscriptionItem
{
Plan = null,
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should handle null Plan gracefully
Assert.Null(result.ProductId);
Assert.Null(result.Name);
Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null
Assert.Null(result.Interval);
Assert.Equal(1, result.Quantity);
Assert.False(result.SponsoredSubscriptionItem);
Assert.False(result.AddonSubscriptionItem);
}
[Fact]
public void BillingSubscriptionItem_NullAmount_SetsToZero()
{
// Arrange - SubscriptionItem with Plan but null Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = null, // Null amount
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should default to 0 when Amount is null
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null
Assert.Equal("month", result.Interval);
Assert.Equal(1, result.Quantity);
}
[Fact]
public void BillingSubscriptionItem_ZeroAmount_PreservesZero()
{
// Arrange - SubscriptionItem with Plan and zero Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = 0, // Zero amount (0 cents)
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should preserve zero amount
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Zero amount preserved
Assert.Equal("month", result.Interval);
}
[Fact]
public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero()
{
// Arrange - Invoice with zero AmountDue
// Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0
// The null-coalescing operator (?? 0) in the constructor handles the case where
// ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable,
// this test verifies the conversion path works correctly for zero values
var invoice = new Invoice
{
AmountDue = 0, // Zero amount due (0 cents)
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert zero correctly
Assert.Equal(0m, result.Amount);
Assert.NotNull(result.Date);
}
[Fact]
public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly()
{
// Arrange - Invoice with valid AmountDue
var invoice = new Invoice
{
AmountDue = 2500, // 2500 cents = $25.00
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert correctly
Assert.Equal(25.00m, result.Amount); // Converted from cents
Assert.NotNull(result.Date);
}
}

View File

@@ -1,5 +1,8 @@
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
@@ -9,7 +12,10 @@ public class HandlebarMailRendererTests
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var renderer = new HandlebarMailRenderer(logger, globalSettings);
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
@@ -17,4 +23,150 @@ public class HandlebarMailRendererTests
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
[Fact]
public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create test template files on disk
var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs");
var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs");
await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: <b>{{Name}}</b>");
await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
var view = new TestMailView { Name = "Jane Doe" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Custom HTML: <b>Jane Doe</b>", html.Trim());
Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
[Theory]
[InlineData("../../../etc/passwd")]
[InlineData("../../../../malicious.txt")]
[InlineData("../../malicious.txt")]
[InlineData("../malicious.txt")]
public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath)
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create a malicious file outside the template directory
var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt");
await File.WriteAllTextAsync(maliciousFile, "Malicious Content");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
// Use reflection to call the private ReadSourceFromDiskAsync method
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var task = (Task<string?>)method!.Invoke(renderer, new object[] { maliciousPath })!;
var result = await task;
// Should return null and not load the malicious file
Assert.Null(result);
// Verify that a warning was logged for the path traversal attempt
logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Cleanup malicious file
if (File.Exists(maliciousFile))
{
File.Delete(maliciousFile);
}
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
[Fact]
public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create a test template file
var templateFileName = "TestTemplate.hbs";
var templatePath = Path.Combine(tempDir, templateFileName);
await File.WriteAllTextAsync(templatePath, "Test Content");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
// Try to read with different case (should work on case-insensitive file systems like Windows/macOS)
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var task = (Task<string?>)method!.Invoke(renderer, new object[] { templateFileName })!;
var result = await task;
// Should successfully read the file
Assert.Equal("Test Content", result);
// Verify no warning was logged
logger.DidNotReceive().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
}

View File

@@ -1,18 +1,24 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);
var mail = new TestMail.TestMail()
{

View File

@@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
}
[Fact]
public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("test@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "user@company.com"
};
var organizationName = "Bitwarden Corp";
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("user@company.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Bitwarden Corp") &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "family@example.com"
};
var familyOrganizationName = "Smith Family";
// Act
await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("family@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Smith Family") &&
m.Category == "Welcome"));
}
[Theory]
[InlineData("Acme Corp", "Acme Corp")]
[InlineData("Company & Associates", "Company &amp; Associates")]
[InlineData("Test \"Quoted\" Org", "Test &quot;Quoted&quot; Org")]
public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.HtmlContent.Contains(expectedSanitized) &&
!m.HtmlContent.Contains("<script>") && // Ensure script tags are removed
m.Category == "Welcome"));
}
[Theory]
[InlineData("test@example.com")]
[InlineData("user+tag@domain.co.uk")]
[InlineData("admin@organization.org")]
public async Task SendIndividualUserWelcomeEmailAsync_HandlesVariousEmailFormats(string email)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = email
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.ToEmails.Contains(email)));
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
@@ -515,4 +516,399 @@ public class StripePaymentServiceTests
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 20m,
AmountOff = 1400
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount
},
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 15m,
AmountOff = null
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should use subscription discount as fallback
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(15m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 25m
},
End = null
};
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = "different-coupon-id",
PercentOff = 10m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount // Should prefer this
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should prefer customer discount over subscription discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null
},
Discounts = new List<Discount>(), // Empty list, no discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Multiple subscription-level discounts, no customer discount
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var firstDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-10-percent",
PercentOff = 10m
},
End = null
};
var secondDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-20-percent",
PercentOff = 20m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
// Multiple subscription discounts - FirstOrDefault() should select the first one
Discounts = new List<Discount> { firstDiscount, secondDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should select the first discount from the list (FirstOrDefault() behavior)
Assert.NotNull(result.CustomerDiscount);
Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id);
Assert.Equal(10m, result.CustomerDiscount.PercentOff);
// Verify the second discount was not selected
Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id);
Assert.NotEqual(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Customer (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = null, // Customer not expanded or null
Discounts = new List<Discount>(), // Empty discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Customer gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Discounts (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = null, // Discounts not expanded or null
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Discounts gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer { Discount = null },
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter
.SubscriptionGetAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Verify expand options are correct
await stripeAdapter.Received(1).SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Is<SubscriptionGetOptions>(o =>
o.Expand.Contains("customer.discount.coupon.applies_to") &&
o.Expand.Contains("discounts.coupon.applies_to") &&
o.Expand.Contains("test_clock")));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.GatewaySubscriptionId = null;
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result);
Assert.Null(result.Subscription);
Assert.Null(result.CustomerDiscount);
Assert.Null(result.UpcomingInvoice);
// Verify no Stripe API calls were made
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceive()
.SubscriptionGetAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
}

View File

@@ -238,4 +238,55 @@ public class ImportCiphersAsyncCommandTests
Assert.Equal("This organization can only have a maximum of " +
$"{organization.MaxCollections} collections.", exception.Message);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithNullImportingOrgUser_SkipsCollectionUserCreation(
Organization organization,
Guid importingUserId,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
organization.MaxCollections = null;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Simulate provider-created org with no members - importing user is NOT an org member
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns((OrganizationUser)null);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection>());
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
// Verify ciphers were created but no CollectionUser entries were created (because the organization user (importingUserId) is null)
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(
ciphers,
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count),
Arg.Is<IEnumerable<CollectionCipher>>(cc => cc.Count() == ciphers.Count),
Arg.Is<IEnumerable<CollectionUser>>(cus => !cus.Any()));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
}

View File

@@ -0,0 +1,171 @@
using Bit.Core.Settings;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.Utilities;
public class ExtendedCacheServiceCollectionExtensionsTests
{
private readonly IServiceCollection _services;
private readonly GlobalSettings _globalSettings;
public ExtendedCacheServiceCollectionExtensionsTests()
{
_services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
_globalSettings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(_globalSettings);
_services.TryAddSingleton(config);
_services.TryAddSingleton(_globalSettings);
_services.TryAddSingleton<IGlobalSettings>(_globalSettings);
_services.AddLogging();
}
[Fact]
public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Duration", "00:12:00" },
{ "GlobalSettings:DistributedCache:FailSafeMaxDuration", "01:30:00" },
{ "GlobalSettings:DistributedCache:FailSafeThrottleDuration", "00:01:00" },
{ "GlobalSettings:DistributedCache:EagerRefreshThreshold", "0.75" },
{ "GlobalSettings:DistributedCache:FactorySoftTimeout", "00:00:00.020" },
{ "GlobalSettings:DistributedCache:FactoryHardTimeout", "00:00:03" },
{ "GlobalSettings:DistributedCache:DistributedCacheSoftTimeout", "00:00:00.500" },
{ "GlobalSettings:DistributedCache:DistributedCacheHardTimeout", "00:00:01.500" },
{ "GlobalSettings:DistributedCache:JitterMaxDuration", "00:00:05" },
{ "GlobalSettings:DistributedCache:IsFailSafeEnabled", "false" },
{ "GlobalSettings:DistributedCache:AllowBackgroundDistributedCacheOperations", "false" },
});
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(12), options.Duration);
Assert.False(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(1.5), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromMinutes(1), options.FailSafeThrottleDuration);
Assert.Equal(0.75f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(20), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(3000), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(0.5), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(1.5), options.DistributedCacheHardTimeout);
Assert.False(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(5), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_DefaultSettings_ConfiguresExpectedValues()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(30), options.Duration);
Assert.True(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(2), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromSeconds(30), options.FailSafeThrottleDuration);
Assert.Equal(0.9f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(100), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(1500), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(1), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(2), options.DistributedCacheHardTimeout);
Assert.True(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(2), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
var registrations = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList();
Assert.Single(registrations);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.NotNull(fusionCache);
}
[Fact]
public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
}
[Fact]
public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.AddSingleton(Substitute.For<IDistributedCache>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
var distributedCache = provider.GetRequiredService<IDistributedCache>();
Assert.NotNull(distributedCache);
}
[Fact]
public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.False(fusionCache.HasDistributedCache);
Assert.False(fusionCache.HasBackplane);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@@ -13,7 +13,7 @@ public class StaticStoreTests
var plans = StaticStore.Plans.ToList();
Assert.NotNull(plans);
Assert.NotEmpty(plans);
Assert.Equal(22, plans.Count);
Assert.Equal(23, plans.Count);
}
[Theory]
@@ -34,8 +34,8 @@ public class StaticStoreTests
{
// Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/
// URLs can contain unicode characters that to a computer would point to completely seperate domains but to the
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
// url update that could be missed in code review and then if they got a user to that URL Bitwarden could
// consider it equivalent with a cipher in the users vault and offer autofill when we should not.
// GitHub does now show a warning on non-ascii characters but it could still be missed.

View File

@@ -1484,6 +1484,8 @@ public class OrganizationUserRepositoryTests
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
const string key = "test-key";
orgUser.Key = key;
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
@@ -1493,6 +1495,7 @@ public class OrganizationUserRepositoryTests
var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status);
Assert.Equal(key, updatedUser.Key);
// Annul
await organizationRepository.DeleteAsync(organization);