Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
60 changes: 60 additions & 0 deletions test/SeederApi.IntegrationTest/SeedControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<SceneResponseModel>();
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<SceneResponseModel>();
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<IOrganizationRepository>();
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; }
Expand Down
11 changes: 11 additions & 0 deletions util/Seeder/Factories/OrganizationSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ internal static Organization Create(string name, string domain, int seats, IMang
return org;
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
Expand Down
22 changes: 22 additions & 0 deletions util/Seeder/Factories/PlanFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand Down
24 changes: 24 additions & 0 deletions util/Seeder/Options/OrganizationOverrides.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,33 @@
/// </summary>
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; }
}
20 changes: 19 additions & 1 deletion util/Seeder/Scenes/SingleOrganizationScene.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,14 +44,23 @@ public class Request
public bool EnableSecretsManager { get; set; }
public int? SmSeats { get; set; }
public int? SmServiceAccounts { get; set; }

/// <summary>
/// Optional capability flag overrides applied on top of the plan defaults.
/// </summary>
public OrganizationOverrides? Overrides { get; set; }

public GatewayType? Gateway { get; set; }
public string? GatewayCustomerId { get; set; }
public string? GatewaySubscriptionId { get; set; }
}

public async Task<SceneResult<SingleOrganizationSceneResult>> SeedAsync(Request 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))
Expand All @@ -70,6 +80,14 @@ public async Task<SceneResult<SingleOrganizationSceneResult>> 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);
Expand Down
39 changes: 39 additions & 0 deletions util/SeederApi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading