From 60bbf00160692a376f5a04bc41f583e87cfb233d Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Tue, 24 Feb 2026 07:47:29 +0100 Subject: [PATCH] Seeder/resolve owner roster quirk (#7059) --- .../RecipeBuilderValidationTests.cs | 42 +++++++++++++++++-- util/Seeder/Pipeline/PresetLoader.cs | 17 ++++---- util/Seeder/Pipeline/RecipeBuilder.cs | 4 +- .../Pipeline/RecipeBuilderExtensions.cs | 16 +++++-- util/Seeder/Steps/CreateRosterStep.cs | 7 ++++ 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs index 38b1049b6d..466b8a93b8 100644 --- a/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs +++ b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs @@ -1,5 +1,7 @@ using Bit.Seeder; +using Bit.Seeder.Models; using Bit.Seeder.Pipeline; +using Bit.Seeder.Services; using Xunit; namespace Bit.SeederApi.IntegrationTest; @@ -13,7 +15,7 @@ public class RecipeBuilderValidationTests var builder = services.AddRecipe("test"); builder.AddUsers(10); - var ex = Assert.Throws(() => builder.UseRoster("test")); + var ex = Assert.Throws(() => builder.UseRoster("test", _stubReader)); Assert.Contains("Cannot call UseRoster() after AddUsers()", ex.Message); } @@ -23,7 +25,7 @@ public class RecipeBuilderValidationTests var services = new ServiceCollection(); var builder = services.AddRecipe("test"); - builder.UseRoster("test"); + builder.UseRoster("test", _stubReader); var ex = Assert.Throws(() => builder.AddUsers(10)); Assert.Contains("Cannot call AddUsers() after UseRoster()", ex.Message); } @@ -86,7 +88,7 @@ public class RecipeBuilderValidationTests var services = new ServiceCollection(); var builder = services.AddRecipe("test"); - builder.UseRoster("test"); + builder.UseRoster("test", _stubReader); builder.AddCollections(5); } @@ -112,6 +114,18 @@ public class RecipeBuilderValidationTests Assert.Contains("Owner is required", ex.Message); } + [Fact] + public void Validate_WithRosterOwner_Succeeds() + { + var services = new ServiceCollection(); + var builder = services.AddRecipe("test"); + + builder.UseOrganization("test"); + builder.UseRoster("test", _stubReaderWithOwner); + + builder.Validate(); // should not throw — roster provides the owner + } + [Fact] public void Validate_AddCiphersWithoutGenerator_ThrowsInvalidOperationException() { @@ -154,4 +168,26 @@ public class RecipeBuilderValidationTests Assert.Equal(i, orderedSteps[i].Order); } } + + private static readonly ISeedReader _stubReader = new StubSeedReader(hasOwner: false); + private static readonly ISeedReader _stubReaderWithOwner = new StubSeedReader(hasOwner: true); + + /// + /// Stub reader for builder validation tests that don't need real fixture data. + /// + private sealed class StubSeedReader(bool hasOwner) : ISeedReader + { + public T Read(string seedName) => + (T)(object)new SeedRoster + { + Users = [new SeedRosterUser + { + FirstName = "Test", + LastName = "User", + Role = hasOwner ? "owner" : "user" + }] + }; + + public IReadOnlyList ListAvailable() => []; + } } diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs index ac369007a1..e882dd31db 100644 --- a/util/Seeder/Pipeline/PresetLoader.cs +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -34,7 +34,7 @@ internal static class PresetLoader /// Builds a recipe from preset configuration, resolving fixtures and generation counts. /// /// - /// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers + /// Resolution order: Org → Roster → Owner (if no roster owner) → Generator → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers /// private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReader reader, IServiceCollection services) { @@ -62,7 +62,15 @@ internal static class PresetLoader domain = org.Domain; } - builder.AddOwner(); + if (preset.Roster?.Fixture is not null) + { + builder.UseRoster(preset.Roster.Fixture, reader); + } + + if (!builder.HasRosterOwner) + { + builder.AddOwner(); + } // Generator requires a domain and is needed for generated ciphers, personal ciphers, or folders if (domain is not null && (preset.Ciphers?.Count > 0 || preset.PersonalCiphers?.CountPerUser > 0 || preset.Folders == true)) @@ -70,11 +78,6 @@ internal static class PresetLoader builder.WithGenerator(domain); } - if (preset.Roster?.Fixture is not null) - { - builder.UseRoster(preset.Roster.Fixture); - } - if (preset.Users is not null) { builder.AddUsers(preset.Users.Count, preset.Users.RealisticStatusMix); diff --git a/util/Seeder/Pipeline/RecipeBuilder.cs b/util/Seeder/Pipeline/RecipeBuilder.cs index 6f4cfc6751..e50ef1e514 100644 --- a/util/Seeder/Pipeline/RecipeBuilder.cs +++ b/util/Seeder/Pipeline/RecipeBuilder.cs @@ -9,7 +9,7 @@ namespace Bit.Seeder.Pipeline; /// /// Wraps and a recipe name, tracking step count for /// deterministic ordering and validation flags for dependency rules. -/// Phase Order: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers +/// Phase Order: Org → Roster → Owner (if no roster owner) → Generator → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers /// public class RecipeBuilder { @@ -43,6 +43,8 @@ public class RecipeBuilder internal bool HasCipherFolderAssignment { get; set; } + internal bool HasRosterOwner { get; set; } + internal bool HasPersonalCiphers { get; set; } /// diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index b44b8d3b65..c465d9d713 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -2,6 +2,8 @@ using Bit.Core.Vault.Enums; using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; +using Bit.Seeder.Models; +using Bit.Seeder.Services; using Bit.Seeder.Steps; namespace Bit.Seeder.Pipeline; @@ -75,8 +77,9 @@ public static class RecipeBuilderExtensions /// The recipe builder /// Roster fixture name without extension /// The builder for fluent chaining + /// Seed reader for peeking the roster fixture to detect owner declarations /// Thrown when AddUsers() was already called - public static RecipeBuilder UseRoster(this RecipeBuilder builder, string fixture) + public static RecipeBuilder UseRoster(this RecipeBuilder builder, string fixture, ISeedReader reader) { if (builder.HasGeneratedUsers) { @@ -85,6 +88,13 @@ public static class RecipeBuilderExtensions } builder.HasRosterUsers = true; + + var roster = reader.Read($"rosters.{fixture}"); + if (roster.Users.Any(u => string.Equals(u.Role, "owner", StringComparison.OrdinalIgnoreCase))) + { + builder.HasRosterOwner = true; + } + builder.AddStep(_ => new CreateRosterStep(fixture)); return builder; } @@ -274,10 +284,10 @@ public static class RecipeBuilderExtensions "Organization is required. Call UseOrganization() or CreateOrganization()."); } - if (!builder.HasOwner) + if (!builder.HasOwner && !builder.HasRosterOwner) { throw new InvalidOperationException( - "Owner is required. Call AddOwner()."); + "Owner is required. Call AddOwner() or declare a user with role 'owner' in the roster."); } if (builder.HasGeneratedCiphers && !builder.HasGenerator) diff --git a/util/Seeder/Steps/CreateRosterStep.cs b/util/Seeder/Steps/CreateRosterStep.cs index 43f266a459..3cf63dbdb9 100644 --- a/util/Seeder/Steps/CreateRosterStep.cs +++ b/util/Seeder/Steps/CreateRosterStep.cs @@ -45,6 +45,13 @@ internal sealed class CreateRosterStep(string fixtureName) : IStep var orgUser = org.CreateOrganizationUserWithKey( user, orgUserType, OrganizationUserStatusType.Confirmed, userOrgKey); + // Promote the first owner-role user to pipeline owner + if (orgUserType == OrganizationUserType.Owner && context.Owner is null) + { + context.Owner = user; + context.OwnerOrgUser = orgUser; + } + userLookup[emailPrefix] = orgUser.Id; context.Users.Add(user);