mirror of
https://github.com/bitwarden/server.git
synced 2026-01-11 19:57:01 +00:00
Add tests to validate license property synchronization pipeline
This commit is contained in:
parent
1977151786
commit
35a9845ab6
5 changed files with 362 additions and 3 deletions
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal file
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue