1
0
mirror of https://github.com/bitwarden/server synced 2026-02-24 16:42:52 +00:00

Seeder Enhancements - Phase 3 (#6973)

This commit is contained in:
Mick Letofsky
2026-02-17 07:42:53 +01:00
committed by GitHub
parent b03f8f8cae
commit 07049b367a
61 changed files with 4982 additions and 51 deletions

View File

@@ -0,0 +1,51 @@
using Bit.Seeder.Recipes;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class OrganizationFromPresetRecipeTests
{
// NOTE: Issue #1 (SeedResult counts) is verified by the implementation fix.
// The Recipe now captures counts BEFORE BulkCommitter.Commit() clears the lists.
// Full database integration tests will verify the counts match actual seeded entities.
// This fix ensures context.Users.Count etc. are captured before being cleared to zero.
[Fact]
public void ListAvailable_HandlesPresetWithPresetInMiddle()
{
// Issue #3: String.Replace bug - should only remove prefix, not all occurrences
var available = OrganizationFromPresetRecipe.ListAvailable();
// Verify presets don't have "presets." prefix removed from middle
// If we had a preset named "my-presets-collection", it should become "my-presets-collection"
// not "my--collection" (which would happen with Replace)
Assert.NotNull(available);
Assert.NotNull(available.Presets);
// All preset names should not start with "presets."
Assert.All(available.Presets, name => Assert.DoesNotContain("presets.", name.Substring(0, Math.Min(8, name.Length))));
// Verify known presets are listed correctly
Assert.Contains("dunder-mifflin-full", available.Presets);
Assert.Contains("large-enterprise", available.Presets);
}
[Fact]
public void ListAvailable_GroupsFixturesByCategory()
{
var available = OrganizationFromPresetRecipe.ListAvailable();
// Verify fixtures are grouped by category
Assert.NotNull(available.Fixtures);
Assert.True(available.Fixtures.ContainsKey("ciphers"));
Assert.True(available.Fixtures.ContainsKey("organizations"));
Assert.True(available.Fixtures.ContainsKey("rosters"));
// Verify ciphers category has expected fixtures
Assert.Contains("ciphers.autofill-testing", available.Fixtures["ciphers"]);
Assert.Contains("ciphers.public-site-logins", available.Fixtures["ciphers"]);
}
}

View File

@@ -0,0 +1,35 @@
using Bit.Seeder;
using Bit.Seeder.Pipeline;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class PresetLoaderTests
{
[Fact]
public void Load_FixtureOrgWithGeneratedCiphers_InitializesGenerator()
{
// Issue #2: Fixture-based org + generated ciphers should resolve domain from fixture
// This test verifies that when a preset uses a fixture org (no explicit domain)
// but wants to generate ciphers (needs domain for generator), the domain is
// automatically resolved by reading the org fixture.
var services = new ServiceCollection();
var builder = services.AddRecipe("fixture-org-test");
builder
.UseOrganization("dunder-mifflin") // Fixture org (domain in fixture)
.AddOwner()
.WithGenerator("dundermifflin.com") // Generator needs domain
.AddCiphers(50);
// This should NOT throw "Generated ciphers require a generator"
builder.Validate();
using var provider = services.BuildServiceProvider();
var steps = provider.GetKeyedServices<IStep>("fixture-org-test").ToList();
Assert.NotNull(steps);
Assert.NotEmpty(steps);
}
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"SeederApi.IntegrationTest": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53205;http://localhost:53206"
}
}
}

View File

@@ -0,0 +1,157 @@
using Bit.Seeder;
using Bit.Seeder.Pipeline;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class RecipeBuilderValidationTests
{
[Fact]
public void UseRoster_AfterAddUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddUsers(10);
var ex = Assert.Throws<InvalidOperationException>(() => builder.UseRoster("test"));
Assert.Contains("Cannot call UseRoster() after AddUsers()", ex.Message);
}
[Fact]
public void AddUsers_AfterUseRoster_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseRoster("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddUsers(10));
Assert.Contains("Cannot call AddUsers() after UseRoster()", ex.Message);
}
[Fact]
public void UseCiphers_AfterAddCiphers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddCiphers(10);
var ex = Assert.Throws<InvalidOperationException>(() => builder.UseCiphers("test"));
Assert.Contains("Cannot call UseCiphers() after AddCiphers()", ex.Message);
}
[Fact]
public void AddCiphers_AfterUseCiphers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseCiphers("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddCiphers(10));
Assert.Contains("Cannot call AddCiphers() after UseCiphers()", ex.Message);
}
[Fact]
public void AddGroups_WithoutUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddGroups(5));
Assert.Contains("Groups require users", ex.Message);
}
[Fact]
public void AddCollections_WithoutUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddCollections(5));
Assert.Contains("Collections require users", ex.Message);
}
[Fact]
public void AddGroups_AfterAddUsers_Succeeds()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddUsers(10);
builder.AddGroups(5);
}
[Fact]
public void AddCollections_AfterUseRoster_Succeeds()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseRoster("test");
builder.AddCollections(5);
}
[Fact]
public void Validate_WithoutOrg_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddOwner();
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Organization is required", ex.Message);
}
[Fact]
public void Validate_WithoutOwner_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Owner is required", ex.Message);
}
[Fact]
public void Validate_AddCiphersWithoutGenerator_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test");
builder.AddOwner();
builder.AddUsers(10);
builder.AddCiphers(50);
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Generated ciphers require a generator", ex.Message);
}
[Fact]
public void StepsExecuteInRegistrationOrder()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test-org");
builder.AddOwner();
builder.WithGenerator("test.com");
builder.AddUsers(5);
builder.AddGroups(2);
builder.AddCollections(3);
builder.AddCiphers(10);
builder.Validate();
using var provider = services.BuildServiceProvider();
var steps = provider.GetKeyedServices<IStep>("test").ToList();
Assert.Equal(7, steps.Count);
// Verify steps are wrapped in OrderedStep with sequential order values
var orderedSteps = steps.Cast<OrderedStep>().ToList();
for (var i = 0; i < orderedSteps.Count; i++)
{
Assert.Equal(i, orderedSteps[i].Order);
}
}
}

View File

@@ -0,0 +1,128 @@
using Bit.Seeder.Models;
using Bit.Seeder.Services;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class SeedReaderTests
{
private readonly SeedReader _reader = new();
[Fact]
public void ListAvailable_ReturnsAllSeedFiles()
{
var available = _reader.ListAvailable();
Assert.Contains("ciphers.autofill-testing", available);
Assert.Contains("ciphers.public-site-logins", available);
Assert.Contains("organizations.dunder-mifflin", available);
Assert.Contains("rosters.dunder-mifflin", available);
Assert.Contains("presets.dunder-mifflin-full", available);
Assert.Contains("presets.large-enterprise", available);
Assert.Equal(6, available.Count);
}
[Fact]
public void Read_AutofillTesting_DeserializesAllItems()
{
var seedFile = _reader.Read<SeedFile>("ciphers.autofill-testing");
Assert.Equal(18, seedFile.Items.Count);
var types = seedFile.Items.Select(i => i.Type).Distinct().OrderBy(t => t).ToList();
Assert.Contains("login", types);
Assert.Contains("card", types);
Assert.Contains("identity", types);
var logins = seedFile.Items.Where(i => i.Type == "login").ToList();
Assert.All(logins, l => Assert.NotEmpty(l.Login!.Uris!));
}
[Fact]
public void Read_PublicSiteLogins_DeserializesAllItems()
{
var seedFile = _reader.Read<SeedFile>("ciphers.public-site-logins");
Assert.True(seedFile.Items.Count >= 90,
$"Expected at least 90 public site logins, got {seedFile.Items.Count}");
}
[Fact]
public void Read_NonExistentSeed_ThrowsWithAvailableList()
{
var ex = Assert.Throws<InvalidOperationException>(
() => _reader.Read<SeedFile>("does-not-exist"));
Assert.Contains("does-not-exist", ex.Message);
Assert.Contains("ciphers.autofill-testing", ex.Message);
}
[Fact]
public void Read_CipherSeeds_ItemNamesAreUnique()
{
var cipherSeeds = _reader.ListAvailable()
.Where(n => n.StartsWith("ciphers."));
foreach (var seedName in cipherSeeds)
{
var seedFile = _reader.Read<SeedFile>(seedName);
var duplicates = seedFile.Items
.GroupBy(i => i.Name)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.True(duplicates.Count == 0,
$"Seed '{seedName}' has duplicate item names: {string.Join(", ", duplicates)}");
}
}
[Fact]
public void Read_DunderMifflin_DeserializesOrganization()
{
var org = _reader.Read<SeedOrganization>("organizations.dunder-mifflin");
Assert.Equal("Dunder Mifflin", org.Name);
Assert.Equal("dundermifflin.com", org.Domain);
Assert.Equal(70, org.Seats);
}
[Fact]
public void Read_DunderMifflinRoster_DeserializesRoster()
{
var roster = _reader.Read<SeedRoster>("rosters.dunder-mifflin");
Assert.Equal(58, roster.Users.Count);
Assert.NotNull(roster.Groups);
Assert.Equal(14, roster.Groups.Count);
Assert.NotNull(roster.Collections);
Assert.Equal(15, roster.Collections.Count);
// Verify no duplicate email prefixes
var prefixes = roster.Users
.Select(u => $"{u.FirstName}.{u.LastName}".ToLowerInvariant())
.ToList();
Assert.Equal(prefixes.Count, prefixes.Distinct().Count());
// Verify all group members reference valid users
var prefixSet = new HashSet<string>(prefixes, StringComparer.OrdinalIgnoreCase);
foreach (var group in roster.Groups)
{
Assert.All(group.Members, m => Assert.Contains(m, prefixSet));
}
// Verify all collection user/group refs are valid
var groupNames = new HashSet<string>(roster.Groups.Select(g => g.Name), StringComparer.OrdinalIgnoreCase);
foreach (var collection in roster.Collections)
{
if (collection.Groups is not null)
{
Assert.All(collection.Groups, cg => Assert.Contains(cg.Group, groupNames));
}
if (collection.Users is not null)
{
Assert.All(collection.Users, cu => Assert.Contains(cu.User, prefixSet));
}
}
}
}