diff --git a/test/SeederApi.IntegrationTest/Factories/PlanFeaturesOverridesTests.cs b/test/SeederApi.IntegrationTest/Factories/PlanFeaturesOverridesTests.cs new file mode 100644 index 000000000000..70f96a9702a9 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Factories/PlanFeaturesOverridesTests.cs @@ -0,0 +1,98 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Seeder.Factories; +using Bit.Seeder.Options; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.Factories; + +public class PlanFeaturesOverridesTests +{ + private static Organization FreeOrg() + { + var org = new Organization(); + PlanFeatures.Apply(org, PlanType.Free); + return org; + } + + [Fact] + public void ApplyOrganizationOverrides_Null_LeavesPlanDefaults() + { + var org = FreeOrg(); + + PlanFeatures.ApplyOrganizationOverrides(org, null); + + // Free plan disables these capabilities. + Assert.False(org.UseSso); + Assert.False(org.UseGroups); + Assert.False(org.UsePolicies); + } + + [Fact] + public void ApplyOrganizationOverrides_EnablesFlagOnTopOfPlanDefault() + { + var org = FreeOrg(); + + PlanFeatures.ApplyOrganizationOverrides(org, new OrganizationOverrides { UseSso = true }); + + Assert.True(org.UseSso); + } + + [Fact] + public void ApplyOrganizationOverrides_PartiallyPopulated_OnlySetFieldsChange() + { + var org = FreeOrg(); + + // UseSso and UseScim set; UseGroups, UsePolicies, UseKeyConnector left null. + PlanFeatures.ApplyOrganizationOverrides(org, new OrganizationOverrides + { + UseSso = true, + UseScim = true + }); + + Assert.True(org.UseSso); + Assert.True(org.UseScim); + Assert.False(org.UseGroups); + Assert.False(org.UsePolicies); + Assert.False(org.UseKeyConnector); + } + + [Fact] + public void ApplyOrganizationOverrides_CanDisableFlagEnabledByPlan() + { + var org = new Organization(); + PlanFeatures.Apply(org, PlanType.EnterpriseAnnually); + Assert.True(org.UseGroups); + + PlanFeatures.ApplyOrganizationOverrides(org, new OrganizationOverrides { UseGroups = false }); + + Assert.False(org.UseGroups); + } + + [Fact] + public void ApplyBilling_SetsGatewayFields() + { + var org = FreeOrg(); + + OrganizationSeeder.ApplyBilling(org, GatewayType.Stripe, "cus_123", "sub_456"); + + Assert.Equal(GatewayType.Stripe, org.Gateway); + Assert.Equal("cus_123", org.GatewayCustomerId); + Assert.Equal("sub_456", org.GatewaySubscriptionId); + } + + [Fact] + public void ApplyBilling_NullValues_LeaveFieldsUnchanged() + { + var org = FreeOrg(); + org.Gateway = GatewayType.Braintree; + org.GatewayCustomerId = "existing"; + + OrganizationSeeder.ApplyBilling(org, null, null, "sub_only"); + + Assert.Equal(GatewayType.Braintree, org.Gateway); + Assert.Equal("existing", org.GatewayCustomerId); + Assert.Equal("sub_only", org.GatewaySubscriptionId); + } +} diff --git a/test/SeederApi.IntegrationTest/SeedControllerTests.cs b/test/SeederApi.IntegrationTest/SeedControllerTests.cs index a409741ed1d5..60d602e73a9f 100644 --- a/test/SeederApi.IntegrationTest/SeedControllerTests.cs +++ b/test/SeederApi.IntegrationTest/SeedControllerTests.cs @@ -1,4 +1,9 @@ using System.Net; +using System.Text.Json; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Seeder.Options; using Bit.Seeder.Scenes; using Bit.SeederApi.Models.Request; using Bit.SeederApi.Models.Response; @@ -222,6 +227,61 @@ public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult() Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task SeedEndpoint_SingleOrganizationScene_WithOverridesAndGateway_ReturnsOk() + { + var playId = Guid.NewGuid().ToString(); + + // An org owner must already exist with a public key. + var ownerEmail = $"org-owner-{Guid.NewGuid()}@bitwarden.com"; + var userResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement( + new SingleUserScene.Request { Email = ownerEmail, Password = "asdfasdfasdf" }) + }, playId); + userResponse.EnsureSuccessStatusCode(); + + var userResult = await userResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(userResult); + var ownerUserId = ((System.Text.Json.JsonElement)userResult!.Result!).GetProperty("userId").GetGuid(); + + // Seed a Free org but enable SSO via overrides and set the billing gateway triple. + var orgResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleOrganizationScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleOrganizationScene.Request + { + OwnerUserId = ownerUserId, + PlanType = PlanType.Free, + Name = "Override Org", + Domain = "override.example", + Seats = 5, + Overrides = new OrganizationOverrides { UseSso = true }, + Gateway = GatewayType.Stripe, + GatewayCustomerId = "cus_seed_test", + GatewaySubscriptionId = "sub_seed_test" + }) + }, playId); + + orgResponse.EnsureSuccessStatusCode(); + var orgResult = await orgResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(orgResult); + Assert.NotNull(orgResult!.Result); + + var organizationId = ((JsonElement)orgResult.Result!).GetProperty("organizationId").GetGuid(); + + using var scope = _factory.Services.CreateScope(); + var organizationRepository = scope.ServiceProvider.GetRequiredService(); + var organization = await organizationRepository.GetByIdAsync(organizationId); + + Assert.NotNull(organization); + Assert.True(organization!.UseSso); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("cus_seed_test", organization.GatewayCustomerId); + Assert.Equal("sub_seed_test", organization.GatewaySubscriptionId); + } + private class BatchDeleteResponse { public string? Message { get; set; } diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 84d877285cd2..399c775aae83 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -30,6 +30,17 @@ internal static Organization Create(string name, string domain, int seats, IMang return org; } + /// + /// Applies billing gateway identity to an organization so it resembles a real billed org. + /// Only non-null values are set; nulls leave the field unchanged. + /// + internal static void ApplyBilling(Organization org, GatewayType? gateway, string? gatewayCustomerId, string? gatewaySubscriptionId) + { + org.Gateway = gateway ?? org.Gateway; + org.GatewayCustomerId = gatewayCustomerId ?? org.GatewayCustomerId; + org.GatewaySubscriptionId = gatewaySubscriptionId ?? org.GatewaySubscriptionId; + } + /// /// Derives a deterministic 8-char hex string from a domain for safe billing email generation. /// Always applied regardless of mangle flag — billing emails must never be deliverable. diff --git a/util/Seeder/Factories/PlanFeatures.cs b/util/Seeder/Factories/PlanFeatures.cs index b7c319c7a47e..9102b91f0b9f 100644 --- a/util/Seeder/Factories/PlanFeatures.cs +++ b/util/Seeder/Factories/PlanFeatures.cs @@ -88,6 +88,28 @@ internal static void ApplyOrganizationOverrides(Organization org, OrganizationOv org.LimitItemDeletion = overrides.LimitItemDeletion ?? org.LimitItemDeletion; org.LimitCollectionCreation = overrides.LimitCollectionCreation ?? org.LimitCollectionCreation; org.LimitCollectionDeletion = overrides.LimitCollectionDeletion ?? org.LimitCollectionDeletion; + + org.UseGroups = overrides.UseGroups ?? org.UseGroups; + org.UsePolicies = overrides.UsePolicies ?? org.UsePolicies; + org.UseSso = overrides.UseSso ?? org.UseSso; + org.UseKeyConnector = overrides.UseKeyConnector ?? org.UseKeyConnector; + org.UseScim = overrides.UseScim ?? org.UseScim; + org.UseDirectory = overrides.UseDirectory ?? org.UseDirectory; + org.UseEvents = overrides.UseEvents ?? org.UseEvents; + org.UseTotp = overrides.UseTotp ?? org.UseTotp; + org.Use2fa = overrides.Use2fa ?? org.Use2fa; + org.UseApi = overrides.UseApi ?? org.UseApi; + org.UseResetPassword = overrides.UseResetPassword ?? org.UseResetPassword; + org.UseCustomPermissions = overrides.UseCustomPermissions ?? org.UseCustomPermissions; + org.UseOrganizationDomains = overrides.UseOrganizationDomains ?? org.UseOrganizationDomains; + org.UsersGetPremium = overrides.UsersGetPremium ?? org.UsersGetPremium; + org.SelfHost = overrides.SelfHost ?? org.SelfHost; + org.UseRiskInsights = overrides.UseRiskInsights ?? org.UseRiskInsights; + org.UseMyItems = overrides.UseMyItems ?? org.UseMyItems; + org.UseAdminSponsoredFamilies = overrides.UseAdminSponsoredFamilies ?? org.UseAdminSponsoredFamilies; + org.UseInviteLinks = overrides.UseInviteLinks ?? org.UseInviteLinks; + org.SyncSeats = overrides.SyncSeats ?? org.SyncSeats; + org.UsePasswordManager = overrides.UsePasswordManager ?? org.UsePasswordManager; } /// diff --git a/util/Seeder/Options/OrganizationOverrides.cs b/util/Seeder/Options/OrganizationOverrides.cs index 03c3bf4a98f2..76c20b56554f 100644 --- a/util/Seeder/Options/OrganizationOverrides.cs +++ b/util/Seeder/Options/OrganizationOverrides.cs @@ -6,9 +6,33 @@ /// public sealed record OrganizationOverrides { + // Collection management settings. public bool? UseAutomaticUserConfirmation { get; init; } public bool? AllowAdminAccessToAllCollectionItems { get; init; } public bool? LimitItemDeletion { get; init; } public bool? LimitCollectionCreation { get; init; } public bool? LimitCollectionDeletion { get; init; } + + // Capability flags. Set Secrets Manager via PlanFeatures.EnableSecretsManager, not here. + public bool? UseGroups { get; init; } + public bool? UsePolicies { get; init; } + public bool? UseSso { get; init; } + public bool? UseKeyConnector { get; init; } + public bool? UseScim { get; init; } + public bool? UseDirectory { get; init; } + public bool? UseEvents { get; init; } + public bool? UseTotp { get; init; } + public bool? Use2fa { get; init; } + public bool? UseApi { get; init; } + public bool? UseResetPassword { get; init; } + public bool? UseCustomPermissions { get; init; } + public bool? UseOrganizationDomains { get; init; } + public bool? UsersGetPremium { get; init; } + public bool? SelfHost { get; init; } + public bool? UseRiskInsights { get; init; } + public bool? UseMyItems { get; init; } + public bool? UseAdminSponsoredFamilies { get; init; } + public bool? UseInviteLinks { get; init; } + public bool? SyncSeats { get; init; } + public bool? UsePasswordManager { get; init; } } diff --git a/util/Seeder/Scenes/SingleOrganizationScene.cs b/util/Seeder/Scenes/SingleOrganizationScene.cs index 721c31119d12..83b298df0632 100644 --- a/util/Seeder/Scenes/SingleOrganizationScene.cs +++ b/util/Seeder/Scenes/SingleOrganizationScene.cs @@ -6,6 +6,7 @@ using Bit.Core.Utilities; using Bit.RustSDK; using Bit.Seeder.Factories; +using Bit.Seeder.Options; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -43,6 +44,15 @@ public class Request public bool EnableSecretsManager { get; set; } public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } + + /// + /// Optional capability flag overrides applied on top of the plan defaults. + /// + public OrganizationOverrides? Overrides { get; set; } + + public GatewayType? Gateway { get; set; } + public string? GatewayCustomerId { get; set; } + public string? GatewaySubscriptionId { get; set; } } public async Task> SeedAsync(Request request) @@ -50,7 +60,7 @@ public async Task> SeedAsync(Request var user = await userRepository.GetByIdAsync(request.OwnerUserId); if (user == null) { - throw new Exception($"User with ID {request.OwnerUserId} not found."); + throw new InvalidOperationException($"User with ID {request.OwnerUserId} not found."); } if (string.IsNullOrEmpty(user.PublicKey)) @@ -70,6 +80,14 @@ public async Task> SeedAsync(Request orgKeys.PrivateKey, request.PlanType); + PlanFeatures.ApplyOrganizationOverrides(organization, request.Overrides); + + OrganizationSeeder.ApplyBilling( + organization, + request.Gateway, + request.GatewayCustomerId, + request.GatewaySubscriptionId); + if (request.EnableSecretsManager) { PlanFeatures.EnableSecretsManager(organization, request.SmSeats, request.SmServiceAccounts); diff --git a/util/SeederApi/README.md b/util/SeederApi/README.md index 4661b0e04d7c..eff6f6386e09 100644 --- a/util/SeederApi/README.md +++ b/util/SeederApi/README.md @@ -70,6 +70,45 @@ curl -X POST http://localhost:5000/seed \ The `result` contains the data returned by the scene, and `mangleMap` contains ID mappings if ID mangling is enabled. Use the `X-Play-Id` header value to later destroy the seeded data. +### Seeding a Parameterized Organization + +`SingleOrganizationScene` seeds an organization on a chosen plan and links an existing user as a confirmed owner +(seed the owner first via `SingleUserScene` and pass its `userId` as `ownerUserId`). + +Beyond `planType` and `seats`, the request accepts: + +- `overrides` — optional capability/collection-management flags applied **on top of** the plan defaults. Any flag left + unset keeps the plan default. Set Secrets Manager via `enableSecretsManager` (with optional `smSeats` / + `smServiceAccounts`), not via `overrides`. +- `gateway`, `gatewayCustomerId`, `gatewaySubscriptionId` — billing gateway identity, so the seeded org resembles a + real billed org. + +Enum fields (`planType`, `gateway`) must be sent as their **numeric value**. In the example +below, `planType: 0` is `Free` and `gateway: 0` is `Stripe`. + +```bash +curl -X POST http://localhost:5000/seed \ + -H "Content-Type: application/json" \ + -H "X-Play-Id: test-run-123" \ + -d '{ + "template": "SingleOrganizationScene", + "arguments": { + "ownerUserId": "42bcf05d-7ad0-4e27-8b53-b3b700acc664", + "planType": 0, + "name": "Acme", + "domain": "acme.example", + "seats": 5, + "overrides": { + "useSso": true, + "useGroups": true + }, + "gateway": 0, + "gatewayCustomerId": "cus_123", + "gatewaySubscriptionId": "sub_456" + } + }' +``` + ### Querying Data Send a POST request to `/query` to execute read-only queries: