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; using Bit.Core.Billing.Organizations.Commands; 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; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; using NSubstitute; using Xunit; using JsonSerializer = System.Text.Json.JsonSerializer; namespace Bit.Core.Test.Billing.Organizations.Commands; [SutProviderCustomize] public class UpdateOrganizationLicenseCommandTests { private static string LicenseDirectory => Path.GetDirectoryName(OrganizationLicenseDirectory.Value); private static Lazy OrganizationLicenseDirectory => new(() => { // Create a temporary directory to write the license file to var directory = Path.Combine(Path.GetTempPath(), "bitwarden/"); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return directory; }); [Theory, BitAutoData] public async Task UpdateLicenseAsync_UpdatesLicenseFileAndOrganization( SelfHostedOrganizationDetails selfHostedOrg, OrganizationLicense license, SutProvider sutProvider) { var globalSettings = sutProvider.GetDependency(); globalSettings.LicenseDirectory = LicenseDirectory; globalSettings.SelfHosted = true; // Passing values for OrganizationLicense.CanUse // NSubstitute cannot override non-virtual members so we have to ensure the real method passes license.Enabled = true; license.Issued = DateTime.Now.AddDays(-1); license.Expires = DateTime.Now.AddDays(1); license.Version = OrganizationLicense.CurrentLicenseFileVersion; license.InstallationId = globalSettings.Installation.Id; license.LicenseType = LicenseType.Organization; sutProvider.GetDependency().VerifyLicense(license).Returns(true); sutProvider.GetDependency() .GetClaimsPrincipalFromLicense(license) .Returns((ClaimsPrincipal)null); // Passing values for SelfHostedOrganizationDetails.CanUseLicense // NSubstitute cannot override non-virtual members so we have to ensure the real method passes license.Seats = null; license.MaxCollections = null; license.UseGroups = true; license.UsePolicies = true; license.UseSso = true; license.UseKeyConnector = true; license.UseScim = true; license.UseCustomPermissions = true; license.UseResetPassword = true; try { await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); // Assertion: should have saved the license file to disk var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); await using var fs = File.OpenRead(filePath); var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); // Assertion: should have updated and saved the organization // Properties excluded from the comparison below are exceptions to the rule that the Organization mirrors // the OrganizationLicense await sutProvider.GetDependency() .Received(1) .ReplaceAndUpdateCacheAsync(Arg.Is( org => AssertPropertyEqual(license, org, "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion", "LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems", "UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker", "UseDisableSmAdsForUsers") && // Same property but different name, use explicit mapping org.ExpirationDate == license.Expires)); } finally { // Clean up temporary directory Directory.Delete(OrganizationLicenseDirectory.Value, true); } } [Theory, BitAutoData] public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims( SelfHostedOrganizationDetails selfHostedOrg, OrganizationLicense license, SutProvider sutProvider) { var globalSettings = sutProvider.GetDependency(); globalSettings.LicenseDirectory = LicenseDirectory; globalSettings.SelfHosted = true; // Setup license for CanUse validation license.Enabled = true; license.Issued = DateTime.Now.AddDays(-1); license.Expires = DateTime.Now.AddDays(1); license.Version = OrganizationLicense.CurrentLicenseFileVersion; license.InstallationId = globalSettings.Installation.Id; license.LicenseType = LicenseType.Organization; license.Token = "test-token"; // Indicates this is a claims-based license sutProvider.GetDependency().VerifyLicense(license).Returns(true); // Create a ClaimsPrincipal with all organization license claims var claims = new List { new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()), new(OrganizationLicenseConstants.Name, "Test Organization"), new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"), new(OrganizationLicenseConstants.BusinessName, "Test Business"), new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()), new(OrganizationLicenseConstants.Seats, "100"), new(OrganizationLicenseConstants.MaxCollections, "50"), new(OrganizationLicenseConstants.UsePolicies, "true"), new(OrganizationLicenseConstants.UseSso, "true"), new(OrganizationLicenseConstants.UseKeyConnector, "true"), new(OrganizationLicenseConstants.UseScim, "true"), new(OrganizationLicenseConstants.UseGroups, "true"), new(OrganizationLicenseConstants.UseDirectory, "true"), new(OrganizationLicenseConstants.UseEvents, "true"), new(OrganizationLicenseConstants.UseTotp, "true"), new(OrganizationLicenseConstants.Use2fa, "true"), new(OrganizationLicenseConstants.UseApi, "true"), new(OrganizationLicenseConstants.UseResetPassword, "true"), new(OrganizationLicenseConstants.Plan, "Enterprise"), new(OrganizationLicenseConstants.SelfHost, "true"), new(OrganizationLicenseConstants.UsersGetPremium, "true"), new(OrganizationLicenseConstants.UseCustomPermissions, "true"), new(OrganizationLicenseConstants.Enabled, "true"), new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")), new(OrganizationLicenseConstants.LicenseKey, "test-license-key"), new(OrganizationLicenseConstants.UsePasswordManager, "true"), new(OrganizationLicenseConstants.UseSecretsManager, "true"), new(OrganizationLicenseConstants.SmSeats, "25"), new(OrganizationLicenseConstants.SmServiceAccounts, "10"), new(OrganizationLicenseConstants.UseRiskInsights, "true"), new(OrganizationLicenseConstants.UseOrganizationDomains, "true"), new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"), new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"), new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "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)); sutProvider.GetDependency() .GetClaimsPrincipalFromLicense(license) .Returns(claimsPrincipal); // Setup selfHostedOrg for CanUseLicense validation selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license selfHostedOrg.GroupCount = 1; selfHostedOrg.UseGroups = true; selfHostedOrg.UsePolicies = true; selfHostedOrg.UseSso = true; selfHostedOrg.UseKeyConnector = true; selfHostedOrg.UseScim = true; selfHostedOrg.UseCustomPermissions = true; selfHostedOrg.UseResetPassword = true; try { await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); // Assertion: license file should be written to disk var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); await using var fs = File.OpenRead(filePath); var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); // Assertion: organization should be updated with ALL properties extracted from claims await sutProvider.GetDependency() .Received(1) .ReplaceAndUpdateCacheAsync(Arg.Is(org => org.Name == "Test Organization" && org.BillingEmail == "billing@test.com" && org.BusinessName == "Test Business" && org.PlanType == PlanType.EnterpriseAnnually && org.Seats == 100 && org.MaxCollections == 50 && org.UsePolicies == true && org.UseSso == true && org.UseKeyConnector == true && org.UseScim == true && org.UseGroups == true && org.UseDirectory == true && org.UseEvents == true && org.UseTotp == true && org.Use2fa == true && org.UseApi == true && org.UseResetPassword == true && org.Plan == "Enterprise" && org.SelfHost == true && org.UsersGetPremium == true && org.UseCustomPermissions == true && org.Enabled == true && org.LicenseKey == "test-license-key" && org.UsePasswordManager == true && org.UseSecretsManager == true && org.SmSeats == 25 && org.SmServiceAccounts == 10 && org.UseRiskInsights == true && org.UseOrganizationDomains == true && org.UseAdminSponsoredFamilies == true && org.UseAutomaticUserConfirmation == true && org.UseDisableSmAdsForUsers == true && org.UsePhishingBlocker == true)); } finally { // Clean up temporary directory if (Directory.Exists(OrganizationLicenseDirectory.Value)) { Directory.Delete(OrganizationLicenseDirectory.Value, true); } } } [Theory, BitAutoData] public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException( SelfHostedOrganizationDetails selfHostedOrg, OrganizationLicense license, SutProvider sutProvider) { var globalSettings = sutProvider.GetDependency(); globalSettings.LicenseDirectory = LicenseDirectory; globalSettings.SelfHosted = true; // Setup license for CanUse validation license.Enabled = true; license.Issued = DateTime.Now.AddDays(-1); license.Expires = DateTime.Now.AddDays(1); license.Version = OrganizationLicense.CurrentLicenseFileVersion; license.LicenseType = LicenseType.Organization; license.Token = "test-token"; // Indicates this is a claims-based license sutProvider.GetDependency().VerifyLicense(license).Returns(true); // Create a ClaimsPrincipal with WRONG installation ID var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id var claims = new List { new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()), new(OrganizationLicenseConstants.Enabled, "true"), new(OrganizationLicenseConstants.SelfHost, "true") }; var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); sutProvider.GetDependency() .GetClaimsPrincipalFromLicense(license) .Returns(claimsPrincipal); // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); Assert.Contains("The installation ID does not match the current installation.", exception.Message); // Verify organization was NOT saved await sutProvider.GetDependency() .DidNotReceive() .ReplaceAndUpdateCacheAsync(Arg.Any()); } [Theory, BitAutoData] public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException( SelfHostedOrganizationDetails selfHostedOrg, OrganizationLicense license, SutProvider sutProvider) { var globalSettings = sutProvider.GetDependency(); globalSettings.LicenseDirectory = LicenseDirectory; globalSettings.SelfHosted = true; // Setup legacy license (no Token, no claims) license.Token = null; // Legacy license license.Enabled = true; license.Issued = DateTime.Now.AddDays(-2); license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday license.Version = OrganizationLicense.CurrentLicenseFileVersion; license.InstallationId = globalSettings.Installation.Id; license.LicenseType = LicenseType.Organization; license.SelfHost = true; sutProvider.GetDependency().VerifyLicense(license).Returns(true); sutProvider.GetDependency() .GetClaimsPrincipalFromLicense(license) .Returns((ClaimsPrincipal)null); // No claims for legacy license // Passing values for SelfHostedOrganizationDetails.CanUseLicense license.Seats = null; license.MaxCollections = null; license.UseGroups = true; license.UsePolicies = true; license.UseSso = true; license.UseKeyConnector = true; license.UseScim = true; license.UseCustomPermissions = true; license.UseResetPassword = true; // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); Assert.Contains("The license has expired.", exception.Message); // Verify organization was NOT saved await sutProvider.GetDependency() .DidNotReceive() .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) { AssertHelper.AssertPropertyEqual(expected, actual, excludedPropertyStrings); return true; } }