Add tests to validate license property synchronization pipeline
Some checks failed
Collect code references / Check for secret access (push) Has been cancelled
Collect code references / Code reference collection (push) Has been cancelled

This commit is contained in:
Conner Turnbull 2026-01-09 16:34:18 -05:00
parent 1977151786
commit 35a9845ab6
No known key found for this signature in database
5 changed files with 362 additions and 3 deletions

View file

@ -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<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
}
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&

View file

@ -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<string>
{
// 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<string>();
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}");
}
}

View file

@ -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<string>
{
"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}");
}
}

View file

@ -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}");
}
}

View file

@ -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<Organization>());
}
[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<string>
{
"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<Type>(OrganizationLicenseConstants.PropertyName)
var extractedProperties = new HashSet<string>();
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<TYPE>(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)
{