From 35a9845ab623d9badeb2c2ce8c642aa7514ef0bb Mon Sep 17 00:00:00 2001 From: Conner Turnbull Date: Fri, 9 Jan 2026 16:34:18 -0500 Subject: [PATCH] Add tests to validate license property synchronization pipeline --- .../UpdateOrganizationLicenseCommand.cs | 10 ++ .../Entities/OrganizationTests.cs | 106 +++++++++++++++++- .../Billing/Licenses/LicenseConstantsTests.cs | 68 +++++++++++ .../OrganizationLicenseClaimsFactoryTests.cs | 92 +++++++++++++++ .../UpdateOrganizationLicenseCommandTests.cs | 89 ++++++++++++++- 5 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs create mode 100644 test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index 6883f4ee11..000edda1c2 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; @@ -87,6 +88,15 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation); license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers); license.UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker); + license.MaxStorageGb = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxStorageGb); + license.InstallationId = claimsPrincipal.GetValue(OrganizationLicenseConstants.InstallationId); + license.LicenseType = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseType); + license.Issued = claimsPrincipal.GetValue(OrganizationLicenseConstants.Issued); + license.Refresh = claimsPrincipal.GetValue(OrganizationLicenseConstants.Refresh); + license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + license.Trial = claimsPrincipal.GetValue(OrganizationLicenseConstants.Trial); + license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue(OrganizationLicenseConstants.LimitCollectionCreationDeletion); + license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems); } var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) && diff --git a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs index 7fcda324d9..aa0c2c80c3 100644 --- a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs +++ b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs @@ -1,7 +1,10 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Billing.Organizations.Models; using Bit.Test.Common.Helpers; using Xunit; @@ -115,4 +118,105 @@ public class OrganizationTests Assert.True(organization.UseDisableSmAdsForUsers); } + + [Fact] + public void UpdateFromLicense_AppliesAllLicenseProperties() + { + // This test ensures that when a new property is added to OrganizationLicense, + // it is also applied to the Organization in UpdateFromLicense(). + // This is the fourth step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Define properties that don't need to be applied to Organization + var excludedProperties = new HashSet + { + // Internal/computed properties + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not applied to org + "Signature", // Signature-related, not applied to org + "Token", // The JWT itself, not applied to org + "Version", // License version, not stored on org + + // Properties intentionally excluded from UpdateFromLicense + "Id", // Self-hosted org has its own unique Guid + "MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense) + + // Properties not stored on Organization model + "LicenseType", // Not a property on Organization + "InstallationId", // Not a property on Organization + "Issued", // Not a property on Organization + "Refresh", // Not a property on Organization + "ExpirationWithoutGracePeriod", // Not a property on Organization + "Trial", // Not a property on Organization + "Expires", // Mapped to ExpirationDate on Organization (different name) + + // Deprecated properties not applied + "LimitCollectionCreationDeletion", // Deprecated, not applied + "AllowAdminAccessToAllCollectionItems", // Deprecated, not applied + }; + + // 3. Get properties that should be applied + var propertiesThatShouldBeApplied = licenseProperties + .Except(excludedProperties) + .ToHashSet(); + + // 4. Read Organization.UpdateFromLicense source code + var organizationSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs"); + var sourceCode = File.ReadAllText(organizationSourcePath); + + // 5. Find all property assignments in UpdateFromLicense method + // Pattern matches: PropertyName = license.PropertyName + // This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires" + var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)"; + var matches = Regex.Matches(sourceCode, assignmentPattern); + + var appliedProperties = new HashSet(); + foreach (Match match in matches) + { + // Get the license property name (right side of assignment) + var licensePropertyName = match.Groups[2].Value; + appliedProperties.Add(licensePropertyName); + } + + // Special case: Expires is mapped to ExpirationDate + if (appliedProperties.Contains("Expires")) + { + appliedProperties.Add("Expires"); // Already added, but being explicit + } + + // 6. Find missing applications + var missingApplications = propertiesThatShouldBeApplied + .Except(appliedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance + var errorMessage = ""; + if (missingApplications.Any()) + { + errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n"; + errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n"; + foreach (var prop in missingApplications) + { + errorMessage += $" {prop} = license.{prop};\n"; + } + errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly."; + } + + // 8. Assert - if this fails, the error message guides the developer to add the application + Assert.True( + !missingApplications.Any(), + $"\n{errorMessage}"); + } } diff --git a/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs new file mode 100644 index 0000000000..23e3455b84 --- /dev/null +++ b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Organizations.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses; + +public class LicenseConstantsTests +{ + [Fact] + public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty() + { + // This test ensures that when a new property is added to OrganizationLicense, + // a corresponding constant is added to OrganizationLicenseConstants. + // This is the first step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Get all constants from OrganizationLicenseConstants + var constants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 3. Define properties that don't need constants (internal/computed/non-claims properties) + var excludedProperties = new HashSet + { + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not in claims system + "Signature", // Signature-related, not in claims system + "Token", // The JWT itself, not a claim within the token + "Version" // Not in claims system (only in deprecated property-based licenses) + }; + + // 4. Find license properties without corresponding constants + var propertiesWithoutConstants = licenseProperties + .Except(constants) + .Except(excludedProperties) + .OrderBy(p => p) + .ToList(); + + // 5. Build error message with guidance + var errorMessage = ""; + if (propertiesWithoutConstants.Any()) + { + errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n"; + errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n"; + foreach (var prop in propertiesWithoutConstants) + { + errorMessage += $" public const string {prop} = nameof({prop});\n"; + } + } + + // 6. Assert - if this fails, the error message guides the developer to add the constant + Assert.True( + !propertiesWithoutConstants.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs new file mode 100644 index 0000000000..cfad06baca --- /dev/null +++ b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses.Services.Implementations; + +public class OrganizationLicenseClaimsFactoryTests +{ + [Theory, BitAutoData] + public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization) + { + // This test ensures that when a constant is added to OrganizationLicenseConstants, + // it is also added to the OrganizationLicenseClaimsFactory to generate claims. + // This is the second step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Populate all nullable properties to ensure claims can be generated + // The factory only adds claims for properties that have values + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@test.com"; + organization.BusinessName = "Test Business"; + organization.Plan = "Enterprise"; + organization.LicenseKey = "test-license-key"; + organization.Seats = 100; + organization.MaxCollections = 50; + organization.MaxStorageGb = 10; + organization.SmSeats = 25; + organization.SmServiceAccounts = 10; + organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired + + // Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims + // ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions + var licenseContext = new LicenseContext + { + InstallationId = Guid.NewGuid(), + SubscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(null!) + { + TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past + PeriodStartDate = DateTime.UtcNow, + PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days) + Status = "active" + } + } + }; + + // 2. Generate claims + var factory = new OrganizationLicenseClaimsFactory(); + var claims = await factory.GenerateClaims(organization, licenseContext); + + // 3. Get all constants from OrganizationLicenseConstants + var allConstants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 4. Get claim types from generated claims + var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet(); + + // 5. Find constants that don't have corresponding claims + var constantsWithoutClaims = allConstants + .Except(generatedClaimTypes) + .OrderBy(c => c) + .ToList(); + + // 6. Build error message with guidance + var errorMessage = ""; + if (constantsWithoutClaims.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n"; + errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}")); + errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n"; + foreach (var constant in constantsWithoutClaims) + { + errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n"; + } + errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally."; + } + + // 7. Assert - if this fails, the error message guides the developer to add claim generation + Assert.True( + !constantsWithoutClaims.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index 251b1ea215..46c418e913 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +using System.Reflection; +using System.Security.Claims; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; @@ -160,7 +162,14 @@ public class UpdateOrganizationLicenseCommandTests new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"), new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"), new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"), - new(OrganizationLicenseConstants.UsePhishingBlocker, "true") + new(OrganizationLicenseConstants.UsePhishingBlocker, "true"), + new(OrganizationLicenseConstants.MaxStorageGb, "5"), + new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")), + new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")), + new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")), + new(OrganizationLicenseConstants.Trial, "false"), + new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"), + new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true") }; var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); @@ -333,6 +342,82 @@ public class UpdateOrganizationLicenseCommandTests .ReplaceAndUpdateCacheAsync(Arg.Any()); } + [Fact] + public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided() + { + // This test ensures that when new properties are added to OrganizationLicense, + // they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand. + // If a new constant is added to OrganizationLicenseConstants but not extracted, + // this test will fail with a clear message showing which properties are missing. + + // 1. Get all OrganizationLicenseConstants + var constantFields = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToList(); + + // 2. Define properties that should be excluded (not claims-based or intentionally not extracted) + var excludedProperties = new HashSet + { + "Version", // Not in claims system (only in deprecated property-based licenses) + "Hash", // Signature-related, not extracted from claims + "Signature", // Signature-related, not extracted from claims + "SignatureBytes", // Computed from Signature, not a claim + "Token", // The JWT itself, not extracted from claims + "Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID + }; + + // 3. Get properties that should be extracted from claims + var propertiesThatShouldBeExtracted = constantFields + .Where(c => !excludedProperties.Contains(c)) + .ToHashSet(); + + // 4. Read UpdateOrganizationLicenseCommand source code + var commandSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", + "src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs"); + var sourceCode = await File.ReadAllTextAsync(commandSourcePath); + + // 5. Find all GetValue calls that extract properties from claims + // Pattern matches: license.PropertyName = claimsPrincipal.GetValue(OrganizationLicenseConstants.PropertyName) + var extractedProperties = new HashSet(); + var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)"; + var matches = Regex.Matches(sourceCode, getValuePattern); + + foreach (Match match in matches) + { + extractedProperties.Add(match.Groups[1].Value); + } + + // 6. Find missing extractions + var missingExtractions = propertiesThatShouldBeExtracted + .Except(extractedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance if there are missing extractions + var errorMessage = ""; + if (missingExtractions.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n"; + errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n"; + foreach (var prop in missingExtractions) + { + errorMessage += $" license.{prop} = claimsPrincipal.GetValue(OrganizationLicenseConstants.{prop});\n"; + } + } + + // 8. Assert - if this fails, the error message guides the developer to add the extraction + // Note: We don't check for "extra extractions" because that would be a compile error + // (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist) + Assert.True( + !missingExtractions.Any(), + $"\n{errorMessage}"); + } + // Wrapper to compare 2 objects that are different types private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings) {