mirror of
https://github.com/bitwarden/server
synced 2025-12-11 13:53:40 +00:00
[PM-24284] - milestone 3 (#6543)
* new feature flag * first pass at changes * safeguard against billing-pricing not being deployed yet * handle families pre migration plan * wrong stripe id * tests * unit tests
This commit is contained in:
@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
|
|||||||
{
|
{
|
||||||
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
|
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
|
||||||
PlanType.Free,
|
PlanType.Free,
|
||||||
PlanType.FamiliesAnnually,
|
PlanType.FamiliesAnnually2025,
|
||||||
PlanType.FamiliesAnnually2019
|
PlanType.FamiliesAnnually2019,
|
||||||
|
PlanType.FamiliesAnnually
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly IDataProtector _dataProtector;
|
private readonly IDataProtector _dataProtector;
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
|
|||||||
planName = "Free";
|
planName = "Free";
|
||||||
return true;
|
return true;
|
||||||
case PlanType.FamiliesAnnually:
|
case PlanType.FamiliesAnnually:
|
||||||
|
case PlanType.FamiliesAnnually2025:
|
||||||
case PlanType.FamiliesAnnually2019:
|
case PlanType.FamiliesAnnually2019:
|
||||||
planName = "Families";
|
planName = "Families";
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ public enum PlanType : byte
|
|||||||
EnterpriseAnnually2019 = 5,
|
EnterpriseAnnually2019 = 5,
|
||||||
[Display(Name = "Custom")]
|
[Display(Name = "Custom")]
|
||||||
Custom = 6,
|
Custom = 6,
|
||||||
[Display(Name = "Families")]
|
[Display(Name = "Families 2025")]
|
||||||
FamiliesAnnually = 7,
|
FamiliesAnnually2025 = 7,
|
||||||
[Display(Name = "Teams (Monthly) 2020")]
|
[Display(Name = "Teams (Monthly) 2020")]
|
||||||
TeamsMonthly2020 = 8,
|
TeamsMonthly2020 = 8,
|
||||||
[Display(Name = "Teams (Annually) 2020")]
|
[Display(Name = "Teams (Annually) 2020")]
|
||||||
@@ -48,4 +48,6 @@ public enum PlanType : byte
|
|||||||
EnterpriseAnnually = 20,
|
EnterpriseAnnually = 20,
|
||||||
[Display(Name = "Teams Starter")]
|
[Display(Name = "Teams Starter")]
|
||||||
TeamsStarter = 21,
|
TeamsStarter = 21,
|
||||||
|
[Display(Name = "Families")]
|
||||||
|
FamiliesAnnually = 22,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public static class BillingExtensions
|
|||||||
=> planType switch
|
=> planType switch
|
||||||
{
|
{
|
||||||
PlanType.Custom or PlanType.Free => ProductTierType.Free,
|
PlanType.Custom or PlanType.Free => ProductTierType.Free,
|
||||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||||
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
||||||
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
||||||
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
|
||||||
|
public record Families2025Plan : Plan
|
||||||
|
{
|
||||||
|
public Families2025Plan()
|
||||||
|
{
|
||||||
|
Type = PlanType.FamiliesAnnually2025;
|
||||||
|
ProductTier = ProductTierType.Families;
|
||||||
|
Name = "Families 2025";
|
||||||
|
IsAnnual = true;
|
||||||
|
NameLocalizationKey = "planNameFamilies";
|
||||||
|
DescriptionLocalizationKey = "planDescFamilies";
|
||||||
|
|
||||||
|
TrialPeriodDays = 7;
|
||||||
|
|
||||||
|
HasSelfHost = true;
|
||||||
|
HasTotp = true;
|
||||||
|
UsersGetPremium = true;
|
||||||
|
|
||||||
|
UpgradeSortOrder = 1;
|
||||||
|
DisplaySortOrder = 1;
|
||||||
|
|
||||||
|
PasswordManager = new Families2025PasswordManagerFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||||
|
{
|
||||||
|
public Families2025PasswordManagerFeatures()
|
||||||
|
{
|
||||||
|
BaseSeats = 6;
|
||||||
|
BaseStorageGb = 1;
|
||||||
|
MaxSeats = 6;
|
||||||
|
|
||||||
|
HasAdditionalStorageOption = true;
|
||||||
|
|
||||||
|
StripePlanId = "2020-families-org-annually";
|
||||||
|
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||||
|
BasePrice = 40;
|
||||||
|
AdditionalStoragePricePerGb = 4;
|
||||||
|
|
||||||
|
AllowSeatAutoscale = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,12 +23,12 @@ public record FamiliesPlan : Plan
|
|||||||
UpgradeSortOrder = 1;
|
UpgradeSortOrder = 1;
|
||||||
DisplaySortOrder = 1;
|
DisplaySortOrder = 1;
|
||||||
|
|
||||||
PasswordManager = new TeamsPasswordManagerFeatures();
|
PasswordManager = new FamiliesPasswordManagerFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures
|
private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||||
{
|
{
|
||||||
public TeamsPasswordManagerFeatures()
|
public FamiliesPasswordManagerFeatures()
|
||||||
{
|
{
|
||||||
BaseSeats = 6;
|
BaseSeats = 6;
|
||||||
BaseStorageGb = 1;
|
BaseStorageGb = 1;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
|||||||
"enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020,
|
"enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020,
|
||||||
"enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023,
|
"enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023,
|
||||||
"families" => PlanType.FamiliesAnnually,
|
"families" => PlanType.FamiliesAnnually,
|
||||||
|
"families-2025" => PlanType.FamiliesAnnually2025,
|
||||||
"families-2019" => PlanType.FamiliesAnnually2019,
|
"families-2019" => PlanType.FamiliesAnnually2019,
|
||||||
"free" => PlanType.Free,
|
"free" => PlanType.Free,
|
||||||
"teams-annually" => PlanType.TeamsAnnually,
|
"teams-annually" => PlanType.TeamsAnnually,
|
||||||
@@ -77,7 +78,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
|||||||
=> planType switch
|
=> planType switch
|
||||||
{
|
{
|
||||||
PlanType.Free => ProductTierType.Free,
|
PlanType.Free => ProductTierType.Free,
|
||||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||||
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
||||||
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
||||||
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class PricingClient(
|
|||||||
var plan = await response.Content.ReadFromJsonAsync<Plan>();
|
var plan = await response.Content.ReadFromJsonAsync<Plan>();
|
||||||
return plan == null
|
return plan == null
|
||||||
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
||||||
: new PlanAdapter(plan);
|
: new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
@@ -91,7 +91,7 @@ public class PricingClient(
|
|||||||
var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
|
var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
|
||||||
return plans == null
|
return plans == null
|
||||||
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
||||||
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList();
|
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan))).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BillingException(
|
throw new BillingException(
|
||||||
@@ -137,7 +137,7 @@ public class PricingClient(
|
|||||||
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
|
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetLookupKey(PlanType planType)
|
private string? GetLookupKey(PlanType planType)
|
||||||
=> planType switch
|
=> planType switch
|
||||||
{
|
{
|
||||||
PlanType.EnterpriseAnnually => "enterprise-annually",
|
PlanType.EnterpriseAnnually => "enterprise-annually",
|
||||||
@@ -149,6 +149,10 @@ public class PricingClient(
|
|||||||
PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020",
|
PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020",
|
||||||
PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023",
|
PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023",
|
||||||
PlanType.FamiliesAnnually => "families",
|
PlanType.FamiliesAnnually => "families",
|
||||||
|
PlanType.FamiliesAnnually2025 =>
|
||||||
|
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3)
|
||||||
|
? "families-2025"
|
||||||
|
: "families",
|
||||||
PlanType.FamiliesAnnually2019 => "families-2019",
|
PlanType.FamiliesAnnually2019 => "families-2019",
|
||||||
PlanType.Free => "free",
|
PlanType.Free => "free",
|
||||||
PlanType.TeamsAnnually => "teams-annually",
|
PlanType.TeamsAnnually => "teams-annually",
|
||||||
@@ -164,6 +168,20 @@ public class PricingClient(
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safeguard used until the feature flag is enabled. Pricing service will return the
|
||||||
|
/// 2025PreMigration plan with "families" lookup key. When that is detected and the FF
|
||||||
|
/// is still disabled, set the lookup key to families-2025 so PlanAdapter will assign
|
||||||
|
/// the correct plan.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plan">The plan to preprocess</param>
|
||||||
|
private Plan PreProcessFamiliesPreMigrationPlan(Plan plan)
|
||||||
|
{
|
||||||
|
if (plan.LookupKey == "families" && !featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3))
|
||||||
|
plan.LookupKey = "families-2025";
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
private static PremiumPlan CurrentPremiumPlan => new()
|
private static PremiumPlan CurrentPremiumPlan => new()
|
||||||
{
|
{
|
||||||
Name = "Premium",
|
Name = "Premium",
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
|
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
|
||||||
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
|
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
|
||||||
public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
|
public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
|
||||||
|
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
|
||||||
|
|
||||||
/* Key Management Team */
|
/* Key Management Team */
|
||||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public abstract class SubscriptionUpdate
|
|||||||
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
||||||
=> plan.Type is
|
=> plan.Type is
|
||||||
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
||||||
|
or PlanType.FamiliesAnnually2025
|
||||||
or PlanType.FamiliesAnnually
|
or PlanType.FamiliesAnnually
|
||||||
or PlanType.TeamsStarter2023
|
or PlanType.TeamsStarter2023
|
||||||
or PlanType.TeamsStarter;
|
or PlanType.TeamsStarter;
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ public static class StaticStore
|
|||||||
new Teams2019Plan(true),
|
new Teams2019Plan(true),
|
||||||
new Teams2019Plan(false),
|
new Teams2019Plan(false),
|
||||||
new Families2019Plan(),
|
new Families2019Plan(),
|
||||||
|
new Families2025Plan()
|
||||||
}.ToImmutableList();
|
}.ToImmutableList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
|||||||
PlanType.Free,
|
PlanType.Free,
|
||||||
PlanType.Custom,
|
PlanType.Custom,
|
||||||
PlanType.FamiliesAnnually2019,
|
PlanType.FamiliesAnnually2019,
|
||||||
|
PlanType.FamiliesAnnually2025,
|
||||||
PlanType.FamiliesAnnually
|
PlanType.FamiliesAnnually
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ public class ConfirmOrganizationUserCommandTests
|
|||||||
[BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
|
[BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
|
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
|
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
|
||||||
|
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Owner)]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
|
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
|
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
|
||||||
[BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]
|
[BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class CloudICloudOrganizationSignUpCommandTests
|
|||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||||
|
[BitAutoData(PlanType.FamiliesAnnually2025)]
|
||||||
public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
||||||
{
|
{
|
||||||
signup.Plan = planType;
|
signup.Plan = planType;
|
||||||
@@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||||
|
[BitAutoData(PlanType.FamiliesAnnually2025)]
|
||||||
public async Task SignUp_AssignsOwnerToDefaultCollection
|
public async Task SignUp_AssignsOwnerToDefaultCollection
|
||||||
(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
||||||
{
|
{
|
||||||
|
|||||||
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal file
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal 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""
|
||||||
|
}}
|
||||||
|
}}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static TheoryData<Plan> NonSmPlans =>
|
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([
|
public static TheoryData<Plan> SmPlans => ToPlanTheory([
|
||||||
PlanType.EnterpriseAnnually2019,
|
PlanType.EnterpriseAnnually2019,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class StaticStoreTests
|
|||||||
var plans = StaticStore.Plans.ToList();
|
var plans = StaticStore.Plans.ToList();
|
||||||
Assert.NotNull(plans);
|
Assert.NotNull(plans);
|
||||||
Assert.NotEmpty(plans);
|
Assert.NotEmpty(plans);
|
||||||
Assert.Equal(22, plans.Count);
|
Assert.Equal(23, plans.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -34,8 +34,8 @@ public class StaticStoreTests
|
|||||||
{
|
{
|
||||||
// Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/
|
// 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
|
// 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
|
// 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 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
|
// 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.
|
// 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.
|
// GitHub does now show a warning on non-ascii characters but it could still be missed.
|
||||||
|
|||||||
Reference in New Issue
Block a user