mirror of
https://github.com/bitwarden/server
synced 2026-02-25 00:52:57 +00:00
Seeder/resolve owner roster quirk (#7059)
This commit is contained in:
@@ -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<InvalidOperationException>(() => builder.UseRoster("test"));
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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);
|
||||
|
||||
/// <summary>
|
||||
/// Stub reader for builder validation tests that don't need real fixture data.
|
||||
/// </summary>
|
||||
private sealed class StubSeedReader(bool hasOwner) : ISeedReader
|
||||
{
|
||||
public T Read<T>(string seedName) =>
|
||||
(T)(object)new SeedRoster
|
||||
{
|
||||
Users = [new SeedRosterUser
|
||||
{
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Role = hasOwner ? "owner" : "user"
|
||||
}]
|
||||
};
|
||||
|
||||
public IReadOnlyList<string> ListAvailable() => [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ internal static class PresetLoader
|
||||
/// Builds a recipe from preset configuration, resolving fixtures and generation counts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Bit.Seeder.Pipeline;
|
||||
/// <remarks>
|
||||
/// Wraps <see cref="IServiceCollection"/> and a recipe name, tracking step count for
|
||||
/// deterministic ordering and validation flags for dependency rules.
|
||||
/// <strong>Phase Order:</strong> Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers
|
||||
/// <strong>Phase Order:</strong> Org → Roster → Owner (if no roster owner) → Generator → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers
|
||||
/// </remarks>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
/// <param name="builder">The recipe builder</param>
|
||||
/// <param name="fixture">Roster fixture name without extension</param>
|
||||
/// <returns>The builder for fluent chaining</returns>
|
||||
/// <param name="reader">Seed reader for peeking the roster fixture to detect owner declarations</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when AddUsers() was already called</exception>
|
||||
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<SeedRoster>($"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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user