From 9b998cfa0dea2011b0027bc9dec3975c3f0eabab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:13:22 +0300 Subject: [PATCH] test(identity-lab): add critical service workflow regression coverage --- .../Integration/SandboxPersistenceTests.cs | 36 ++++ .../Integration/UserWorkflowTests.cs | 188 ++++++++++++++++++ .../Services/DashboardTests.cs | 78 ++++++++ .../Services/RepairTests.cs | 31 +++ .../Services/SandboxAdminTests.cs | 80 ++++++++ .../docs/service-regression-coverage.md | 119 +++++++++++ 6 files changed, 532 insertions(+) create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs index 2cfe13d0..982ff58a 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs @@ -96,4 +96,40 @@ public async Task Concurrent_creation_cannot_create_two_sandboxes_for_one_owner( Assert.Equal(1, await verify.SandboxSettings.CountAsync()); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Ensure_restores_owner_relationship_without_changing_existing_sandbox(bool missingMembership) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var service = new SandboxService(db); + var original = (await service.GetByUserAsync(LabDatabase.OwnerA))!; + await using (var setup = db.CreateDbContext()) + { + var owner = await setup.SandboxMembers.SingleAsync(x => x.UserKey == LabDatabase.OwnerA); + if (missingMembership) + setup.SandboxMembers.Remove(owner); + else + owner.Role = SandboxMemberRole.Member; + await setup.SaveChangesAsync(); + } + var repaired = await service.EnsureAsync(LabDatabase.OwnerA, "Must not replace existing name"); + Assert.Equal(original.Id, repaired.Id); + Assert.Equal(original.Name, repaired.Name); + Assert.Equal(original.Settings.MaxUsers, repaired.Settings.MaxUsers); + var beforeRepeat = await db.SnapshotAsync(); + await service.EnsureAsync(LabDatabase.OwnerA, "Still must not rename"); + Assert.Equal(beforeRepeat, await db.SnapshotAsync()); + await using var verify = db.CreateDbContext(); + var members = await verify.SandboxMembers.ToListAsync(); + Assert.Equal(4, members.Count); + Assert.Equal(SandboxMemberRole.Owner, Assert.Single(members, x => x.UserKey == LabDatabase.OwnerA).Role); + Assert.Contains(members, x => x.UserKey == LabDatabase.MemberA && x.SandboxId == original.Id); + Assert.Contains(members, x => x.UserKey == LabDatabase.OwnerB && x.Role == SandboxMemberRole.Owner); + Assert.Contains(members, x => x.UserKey == LabDatabase.MemberB && x.SandboxId != original.Id); + Assert.Equal(2, await verify.Sandboxes.CountAsync()); + Assert.Equal(2, await verify.SandboxSettings.CountAsync()); + } + } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs new file mode 100644 index 00000000..205ca000 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/UserWorkflowTests.cs @@ -0,0 +1,188 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; + +public sealed class UserWorkflowTests(PostgresFixture postgres) : IClassFixture +{ + private static async Task RegisterAsync(IServiceProvider sp, LabDatabase db, string name) + { + var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + new(db), new(db, NullLogger.Instance)); + var result = await registration.RegisterAsync(new(name, $"{name}@example.test", "Owner-test-password-8!")); + Assert.True(result.IsSuccess, result.ErrorMessage); + await using var verify = db.CreateDbContext(); + return (await verify.Sandboxes.SingleAsync(x => x.Name == $"{name}'s Sandbox")).OwnerUserKey; + } + + private static LabUserProvisioningService Provisioning(IServiceProvider sp, LabDatabase db, UserKey owner) => + new(ActorProvider.For(owner), new(db), new(db), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), NullLogger.Instance); + + private static SandboxUserService UserService(IServiceProvider sp, LabDatabase db, UserKey owner, IUAuthClient client) => + new(new(db), client, db, new(db), Provisioning(sp, db, owner), + sp.GetRequiredService(), new(db, NullLogger.Instance), + sp.GetRequiredService(), sp.GetRequiredService(), + NullLogger.Instance); + + private static CreateUserRequest Request(string name) => new() + { UserName = name, Email = $"{name}@example.test", Password = "Member-test-password-8!" }; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Created_member_is_queryable_capacity_rejection_leaves_no_account_and_deletion_frees_capacity(bool selfDelete) + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var owner = await RegisterAsync(sp, db, "owner"); + var foreignOwner = await RegisterAsync(sp, db, "foreign"); + var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(foreignOwner, Request("foreign-member")); + Assert.True(foreign.IsSuccess, foreign.FailureReason); + var foreignKey = UserKey.FromString(foreign.UserKey!); + await using (var setup = db.CreateDbContext()) + { + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == owner); + settings.MaxUsers = 2; + await setup.SaveChangesAsync(); + } + var client = new Mock(MockBehavior.Strict); + var userClient = new Mock(MockBehavior.Strict); + client.SetupGet(x => x.Users).Returns(userClient.Object); + var service = UserService(sp, db, owner, client.Object); + await service.CreateAsync(owner, Request("member")); + var visible = await service.GetUsersAsync(owner); + Assert.NotNull(visible); + var member = Assert.Single(visible.Users, x => !x.IsOwner).UserKey; + Assert.Equal(2, visible.Users.Count); + Assert.DoesNotContain(visible.Users, x => x.UserKey == foreignKey); + var beforeRejected = await db.SnapshotAsync(); + // The facade reports failures; assert persistence rather than dialog wording. + await service.CreateAsync(owner, Request("rejected-member")); + Assert.Equal(beforeRejected, await db.SnapshotAsync()); + await using (var fresh = runtime.Services.CreateAsyncScope()) + { + var summaries = fresh.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var all = await summaries.QueryAsync(new UserQuery { PageSize = 100, IncludeDeleted = true }); + Assert.DoesNotContain(all.Items, x => x.UserName == "rejected-member"); + } + Assert.False((await service.DeleteAsync(owner, foreignKey)).Success); + Assert.Equal(beforeRejected, await db.SnapshotAsync()); + userClient.VerifyNoOtherCalls(); + var application = sp.GetRequiredService(); + if (selfDelete) + { + userClient.Setup(x => x.DeleteMeAsync()).Returns(async () => + { + await application.DeleteMeAsync(Contexts.Create("users.delete.self", member, member)); + return new UAuthResult { IsSuccess = true }; + }); + Assert.True(await new SandboxAccountDeletionService(db, client.Object, + NullLogger.Instance).DeleteMemberAsync(member)); + userClient.Verify(x => x.DeleteMeAsync(), Times.Once); + } + else + { + userClient.Setup(x => x.DeleteUserAsync(member, It.IsAny())).Returns(async (UserKey target, DeleteUserRequest request) => + { + Assert.Equal(DeleteMode.Hard, request.Mode); + var result = await application.DeleteUserAsync(Contexts.Create("users.delete.admin", owner, target), request); + return new UAuthResult { IsSuccess = result.IsSuccess, Value = result }; + }); + Assert.True((await service.DeleteAsync(owner, member)).Success); + userClient.Verify(x => x.DeleteUserAsync(member, It.IsAny()), Times.Once); + } + await using var verification = runtime.Services.CreateAsyncScope(); + var lifecycleStore = verification.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, member)); + if (selfDelete) { Assert.NotNull(lifecycle); Assert.True(lifecycle.IsDeleted); } + else Assert.Null(lifecycle); + Assert.NotNull(await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, foreignKey))); + var remaining = await service.GetUsersAsync(owner); + Assert.NotNull(remaining); + Assert.Equal(owner, Assert.Single(remaining.Users).UserKey); + // Optional dashboard calls may fail; its persisted count must still be usable. + var dashboard = await new LabDashboardService(new(db), client.Object).GetAsync(owner); + Assert.NotNull(dashboard); + Assert.Equal(1, dashboard.MemberCount); + Assert.Equal(1, dashboard.RemainingCapacity); + await service.CreateAsync(owner, Request("replacement")); + Assert.Equal(2, (await service.GetUsersAsync(owner))!.Users.Count); + await using var final = db.CreateDbContext(); + Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member)); + Assert.Equal(2, await final.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == foreignOwner)); + } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Owner_and_admin_recovery_restore_missing_membership_without_duplicate_or_foreign_changes(bool adminRepair) + { + await using var db = await postgres.CreateAsync(); + await using var runtime = await LabRuntime.CreateAsync(db); + await using var scope = runtime.Services.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var owner = await RegisterAsync(sp, db, "repair-owner"); + var other = await RegisterAsync(sp, db, "other-owner"); + var member = await Provisioning(sp, db, owner).CreateMemberAsync(owner, Request("retained-member")); + Assert.True(member.IsSuccess, member.FailureReason); + var roles = sp.GetRequiredService(); + var assignments = sp.GetRequiredService(); + await using (var setup = db.CreateDbContext()) + { + setup.SandboxMembers.Remove(await setup.SandboxMembers.SingleAsync(x => x.UserKey == owner)); + await setup.SaveChangesAsync(); + } + if (adminRepair) + { + var context = Contexts.Create("identitylab.repair", LabAdminIdentity.UserKey); + var service = new LabRepairService(db, sp.GetRequiredService(), + roles, assignments, new LabAdminGuard(roles, assignments), NullLogger.Instance); + var snapshot = await service.GetSnapshotAsync(context); + Assert.True(snapshot.UserScanComplete); + Assert.Contains(snapshot.OrphanUsers, x => x.UserKey == owner && x.OwnedSandboxId != null); + Assert.DoesNotContain(snapshot.OrphanUsers, x => x.UserKey == other); + Assert.True((await service.RepairOwnerAsync(context, owner)).Success); + var repaired = await db.SnapshotAsync(); + Assert.False((await service.RepairOwnerAsync(context, owner)).Success); + Assert.Equal(repaired, await db.SnapshotAsync()); + Assert.DoesNotContain((await service.GetSnapshotAsync(context)).OrphanUsers, x => x.UserKey == owner); + } + else + { + var context = Contexts.Create("identitylab.repair", owner); + var service = new LabOwnerRepairService(db, sp.GetRequiredService(), + roles, assignments, NullLogger.Instance); + Assert.Equal(OwnerRepairState.CanRepair, (await service.GetStatusAsync(context)).State); + Assert.True((await service.RepairAsync(context)).Success); + var repaired = await db.SnapshotAsync(); + Assert.Equal(OwnerRepairState.NotRequired, (await service.GetStatusAsync(context)).State); + Assert.False((await service.RepairAsync(context)).Success); + Assert.Equal(repaired, await db.SnapshotAsync()); + } + await using var final = db.CreateDbContext(); + var members = await final.SandboxMembers.ToListAsync(); + Assert.Equal(3, members.Count); + var restored = Assert.Single(members, x => x.UserKey == owner); + Assert.Equal(CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMemberRole.Owner, restored.Role); + Assert.Contains(members, x => x.UserKey.Value == member.UserKey && x.SandboxId == restored.SandboxId); + Assert.Contains(members, x => x.UserKey == other && x.SandboxId != restored.SandboxId); + Assert.Equal(2, await final.Sandboxes.CountAsync()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs new file mode 100644 index 00000000..5baaab3d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/DashboardTests.cs @@ -0,0 +1,78 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class DashboardTests(PostgresFixture postgres) : IClassFixture +{ + [Theory] + [InlineData(1, 1, 0)] + [InlineData(5, 5, 3)] + [InlineData(100, SandboxLimits.AbsoluteMaxUsers, 18)] + public async Task Overview_uses_own_membership_and_effective_capacity_when_optional_services_fail( + int configuredLimit, int expectedLimit, int expectedRemaining) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + await using (var setup = db.CreateDbContext()) + { + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA); + settings.MaxUsers = configuredLimit; + await setup.SaveChangesAsync(); + } + var users = new Mock(MockBehavior.Strict); + users.Setup(x => x.GetMeAsync(null)).ThrowsAsync(new InvalidOperationException("Profile unavailable")); + var sessions = new Mock(MockBehavior.Strict); + sessions.Setup(x => x.GetMyChainsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Sessions unavailable")); + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(x => x.GetMyRolesAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Roles unavailable")); + var client = new Mock(MockBehavior.Strict); + client.SetupGet(x => x.Users).Returns(users.Object); + client.SetupGet(x => x.Sessions).Returns(sessions.Object); + client.SetupGet(x => x.Authorization).Returns(authorization.Object); + var service = new LabDashboardService(new(db), client.Object); + + var owner = await service.GetAsync(LabDatabase.OwnerA); + Assert.NotNull(owner); + Assert.Equal("Sandbox A", owner.SandboxName); + Assert.True(owner.IsOwner); + Assert.Equal(2, owner.MemberCount); + Assert.Equal(expectedLimit, owner.MaxUsers); + Assert.Equal(expectedRemaining, owner.RemainingCapacity); + Assert.Null(owner.DisplayName); + Assert.Null(owner.ChainCount); + Assert.Null(owner.RoleCount); + users.VerifyAll(); + sessions.VerifyAll(); + authorization.VerifyAll(); + + var member = await service.GetAsync(LabDatabase.MemberA); + Assert.NotNull(member); + Assert.Equal(owner.SandboxId, member.SandboxId); + Assert.False(member.IsOwner); + Assert.Equal(owner.MemberCount, member.MemberCount); + + Assert.True(await new SandboxService(db).RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA)); + var after = await service.GetAsync(LabDatabase.OwnerA); + Assert.NotNull(after); + Assert.Equal(1, after.MemberCount); + Assert.Equal(expectedLimit - 1, after.RemainingCapacity); + } + + [Fact] + public async Task Missing_membership_returns_no_dashboard_without_requesting_optional_data() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var client = new Mock(MockBehavior.Strict); + Assert.Null(await new LabDashboardService(new(db), client.Object).GetAsync(UserKey.FromString("orphan"))); + client.VerifyNoOtherCalls(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs index a55933f6..fcf6fb76 100644 --- a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs @@ -30,4 +30,35 @@ public async Task Repair_removes_only_own_stale_members_and_preserves_both_owner Assert.Contains(LabDatabase.OwnerA, keys); Assert.Contains(LabDatabase.OwnerB, keys); } + [Fact] + public async Task Preview_matches_repair_and_repeated_repair_preserves_valid_and_unrelated_members() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var validKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString("valid-member"); + await new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, validKey); + var store = new Mock(MockBehavior.Strict); + store.Setup(x => x.GetByKeysAsync( + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { new UserSummary { UserKey = validKey } }); + var stores = new Mock(); + stores.Setup(x => x.Create(It.IsAny())).Returns(store.Object); + var service = new SandboxRepairService(db, stores.Object, NullLogger.Instance); + var before = await db.SnapshotAsync(); + Assert.Equal(new SandboxRepairPreview(3, 1), await service.PreviewAsync(LabDatabase.OwnerA)); + Assert.Equal(before, await db.SnapshotAsync()); + Assert.Equal(1, (await service.RepairAsync(LabDatabase.OwnerA)).RemovedMemberships); + var repaired = await db.SnapshotAsync(); + Assert.Equal(new SandboxRepairPreview(2, 0), await service.PreviewAsync(LabDatabase.OwnerA)); + Assert.Equal(0, (await service.RepairAsync(LabDatabase.OwnerA)).RemovedMemberships); + Assert.Equal(repaired, await db.SnapshotAsync()); + await using var verify = db.CreateDbContext(); + var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync(); + Assert.Equal(4, keys.Count); + Assert.Contains(validKey, keys); + Assert.Contains(LabDatabase.OwnerA, keys); + Assert.Contains(LabDatabase.OwnerB, keys); + Assert.Contains(LabDatabase.MemberB, keys); + Assert.DoesNotContain(LabDatabase.MemberA, keys); + } } diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs new file mode 100644 index 00000000..9ecc085e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/SandboxAdminTests.cs @@ -0,0 +1,80 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using Moq; + +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services; + +public sealed class SandboxAdminTests(PostgresFixture postgres) : IClassFixture +{ + private static readonly AccessContext Admin = Contexts.Create("identitylab.tests", UserKey.FromString("admin")); + + private static SandboxAdminService Create(LabDatabase db) + { + // Guard authorization is covered separately; these tests exercise persisted admin workflows. + var guard = new Mock(MockBehavior.Strict); + guard.Setup(x => x.EnsureAdminAsync(Admin, It.IsAny())).Returns(Task.CompletedTask); + return new(db, guard.Object); + } + + [Fact] + public async Task Capacity_changes_respect_existing_members_and_control_subsequent_additions() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandboxes = new SandboxService(db); + var sandbox = (await sandboxes.GetByUserAsync(LabDatabase.OwnerA))!; + var service = Create(db); + var before = await db.SnapshotAsync(); + await Assert.ThrowsAsync(() => service.SetMaxUsersAsync(Admin, sandbox.Id, 1)); + Assert.Equal(before, await db.SnapshotAsync()); + Assert.True(await service.SetMaxUsersAsync(Admin, sandbox.Id, 2)); + await Assert.ThrowsAsync(() => sandboxes.AddMemberAsync(LabDatabase.OwnerA, UserKey.FromString("new-member"))); + Assert.True(await service.SetMaxUsersAsync(Admin, sandbox.Id, 3)); + await sandboxes.AddMemberAsync(LabDatabase.OwnerA, UserKey.FromString("new-member")); + var summary = await service.GetAsync(Admin, sandbox.Id); + Assert.NotNull(summary); + Assert.Equal(3, summary.MemberCount); + Assert.Equal(3, summary.MaxUsers); + Assert.Equal(2, (await sandboxes.GetMembersAsync((await sandboxes.GetByUserAsync(LabDatabase.OwnerB))!.Id)).Count); + } + + [Theory] + [InlineData("%")] + [InlineData("_")] + [InlineData("\\")] + public async Task Search_treats_postgres_pattern_characters_literally(string literal) + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandbox = (await new SandboxService(db).GetByUserAsync(LabDatabase.OwnerA))!; + var service = Create(db); + Assert.True(await service.RenameAsync(Admin, sandbox.Id, $" Lab {literal} special ")); + var result = await service.QueryAsync(Admin, new SandboxQuery { Search = $" {literal} " }); + Assert.Equal(1, result.TotalCount); + Assert.Equal(sandbox.Id, Assert.Single(result.Items).Id); + Assert.Equal($"Lab {literal} special", result.Items[0].Name); + } + + [Fact] + public async Task Statistics_follow_status_transitions_and_membership_removal() + { + await using var db = await postgres.CreateAsync(); + await db.SeedAsync(); + var sandboxes = new SandboxService(db); + var a = (await sandboxes.GetByUserAsync(LabDatabase.OwnerA))!; + var b = (await sandboxes.GetByUserAsync(LabDatabase.OwnerB))!; + var service = Create(db); + Assert.Equal(new SandboxStatistics(2, 2, 0, 0, 4), await service.GetStatisticsAsync(Admin)); + Assert.True(await sandboxes.RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA)); + Assert.True(await service.SetStatusAsync(Admin, a.Id, SandboxStatus.Passive)); + Assert.True(await service.SetStatusAsync(Admin, b.Id, SandboxStatus.Suspended)); + Assert.Equal(new SandboxStatistics(2, 0, 1, 1, 3), await service.GetStatisticsAsync(Admin)); + var suspended = await service.QueryAsync(Admin, new SandboxQuery { Status = SandboxStatus.Suspended }); + Assert.Equal(1, suspended.TotalCount); + Assert.Equal(b.Id, Assert.Single(suspended.Items).Id); + } +} diff --git a/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md new file mode 100644 index 00000000..f16016fe --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.IdentityLab.Tests/docs/service-regression-coverage.md @@ -0,0 +1,119 @@ +# Service regression coverage addition + +## Assessment + +Reviewed SandboxService, SandboxUserService, LabDashboardService, SandboxAdminService, +LabAdminService, SandboxRepairService, LabOwnerRepairService and LabRepairService, +alongside existing provisioning, registration, deletion, role, authorization and +PostgreSQL integration tests. Existing tests already protect creation relationships, +duplicate owners, membership isolation, capacity contention, member provisioning, +rollback, owner deletion denial and basic stale membership cleanup. Those scenarios +were retained without duplication. + +The selected gaps are dashboard membership aggregation and graceful degradation, +capacity-setting effects on subsequent operations, PostgreSQL literal search, +statistics after mutations, and repeatable repairs that preserve valid relationships. + +## Added tests (12 cases across 7 methods) + +| File / group | Observable behavior and regression detected | Priority rationale | +| --- | --- | --- | +| Services/DashboardTests.cs (4 cases) | Own sandbox's persisted membership count; owner/member distinction; configured and absolute capacity limits; remaining capacity floors at zero; counts update after removal; optional profile/session/role failures do not lose the overview; absent membership avoids client calls | Incorrect counts and optional-service outages affect the main application overview | +| Services/SandboxAdminTests.cs (5 cases) | Reject capacity below member count without writes; full sandbox rejects additions and increased capacity allows them; foreign sandbox preserved; `%`, `_` and backslash searches are literal under PostgreSQL; rename trims; status filtering and statistics reflect persisted transitions/removal | Protects application settings and real provider query behavior | +| Services/RepairTests.cs (1 additional case) | Preview is read-only and predicts cleanup; valid members survive alongside owners and foreign members; repeat repair removes nothing and leaves state identical | Detects overbroad cleanup and non-repeatable repair | +| Integration/SandboxPersistenceTests.cs (2 additional cases) | Ensure restores missing or misclassified owner membership in the original sandbox, retains settings/name and other members, and is repeatable | Protects membership recovery against duplicates and replacement of existing sandbox data | + +All database scenarios reuse the existing isolated PostgreSQL fixture and actual +migrations. Framework clients/stores are mocked only at the existing boundary; +no production code, package dependencies or infrastructure abstractions changed. +Dashboard counts intentionally represent membership rows, as the current service +specifies; these tests do not claim that dashboard reads filter deleted accounts. + +## Validation in this workspace + +- `dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx` + could not start: `dotnet: command not found`. +- SDK bootstrap attempted through the inherited proxy; download failed because + `proxy:8080` was unreachable. TLS verification was not disabled. +- The managed local Docker socket health check returned permission denied. +- Tests executed: 0; passed: 0; failed test cases: 0. All 12 added cases remain + uncompiled/unexecuted; no pass claim is made. Broader suite likewise blocked. +- Static review checked client signatures, DTO property names, existing fixture + conventions and expected service behavior. `git diff --check` passed. +- Existing README documents an intentionally failing + `KnownDefect=SystemRoleIdentity` test. It was preserved; this run cannot confirm + its current outcome. Do not interpret historical execution reports as this run. + +Run the command above with .NET 10 and Docker, or a dedicated PostgreSQL instance +via `IDENTITYLAB_TEST_POSTGRES`. Then run the broader solution suite as appropriate. + +## Remaining risks + +- HIGH: SandboxUserService owner-initiated deletion success/failure and membership + cleanup with framework persistence; self-deletion tests cover a different path. +- HIGH: LabOwnerRepairService eligibility/capacity and LabRepairService owner repair, + scan completeness and activity-history-protected empty sandbox deletion. +- HIGH: Existing system-role identity rejection regression documented by the suite; + outside the permitted testability-only production changes. +- MEDIUM: SandboxUserService missing/deleted summaries; real deletion to repair to + dashboard workflow; dashboard successful optional data and cancellation. +- MEDIUM: LabAdminService's separate update/query/statistics workflows, activity + visibility, standalone availability and diagnostics policy composition. +- LOW: Additional query paging/sort combinations and validation message mappings. + +## Changed files + +Added Services/DashboardTests.cs and Services/SandboxAdminTests.cs. Extended +Services/RepairTests.cs and Integration/SandboxPersistenceTests.cs. Added this +report. No production changes. Work was performed on the supplied `work` checkout +at 45dcfcd (which differs from the locally recorded origin/dev); branch history was +not rewritten or reset. + +## Continuation: critical workflows + +The previous 12 cases were preserved. Static source review found their client +signatures, DTO properties and expected membership-based counts consistent with +current services. Their intentionally inconsistent capacity fixtures test defensive +dashboard calculations, rather than claiming those settings can be applied through +the admin UI. They still require compilation and execution. + +Added Integration/UserWorkflowTests.cs: four cases across two parameterized methods. +The deletion variants compose actual registration, provisioning, SandboxUserService, +summary queries, framework lifecycle/deletion services and both PostgreSQL contexts. +They verify creation visibility, membership-based capacity rejection with no rejected +account (including deleted summaries), foreign deletion denial before client access, +owner hard deletion or member soft deletion, membership cleanup, dashboard count, +replacement provisioning and preservation of the foreign sandbox. The IUAuthClient +boundary is adapted with Moq to invoke the real framework application service; +authenticated contexts use the existing test adapter. These are service integration +tests, not hosted HTTP or cookie authentication tests. Optional dashboard calls use +its existing graceful failure path. + +The repair variants invoke LabOwnerRepairService and LabRepairService with actual +lifecycle, summary, role and assignment stores. Admin recovery uses the real +LabAdminGuard with the seeded administrator. They verify missing-owner detection, +persisted restoration alongside valid members, repeat rejection without mutations, +and preservation of another sandbox. Existing Ensure and stale-member repair tests +exercise different repair entry points and were not duplicated. + +Validation command attempted: +`dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx`. +Result: shell exit 127 (`dotnet: command not found`), no tests executed. No blocked +SDK downloads were retried. Focused and broader execution and compilation remain +unavailable, not failed test cases. `git diff --check` passed. Static review checked +constructors, dependency registrations in LabRuntime, async mock return types, +UserQuery/summary APIs, lifecycle IsDeleted and client deletion result types against +source. No production changes were made. A build command for a capable environment +is `dotnet build tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release`. + +Before merging, compile and run the entire Identity Lab project with .NET 10 and +PostgreSQL, including these four cases and the original 12. Triage the existing +KnownDefect=SystemRoleIdentity failure separately; it has not been removed or run. +The most important workflows now have test implementations, but reliable regression +protection cannot be claimed until execution succeeds. The additional suite remains +small and reuses existing fixtures without new production abstractions. + +Material remaining gaps: framework deletion failure followed by facade membership +preservation; inactive accounts retaining membership and capacity; repair capacity +rejection/new sandbox recovery and activity-history deletion safeguards. Transport +identity validation remains outside these service integration tests.