|
| 1 | +using CodeBeam.UltimateAuth.Authorization; |
| 2 | +using CodeBeam.UltimateAuth.Client; |
| 3 | +using CodeBeam.UltimateAuth.Client.Services; |
| 4 | +using CodeBeam.UltimateAuth.Core.Contracts; |
| 5 | +using CodeBeam.UltimateAuth.Core.Domain; |
| 6 | +using CodeBeam.UltimateAuth.Core.MultiTenancy; |
| 7 | +using CodeBeam.UltimateAuth.IdentityLab.Authorization; |
| 8 | +using CodeBeam.UltimateAuth.IdentityLab.Contracts; |
| 9 | +using CodeBeam.UltimateAuth.IdentityLab.Services; |
| 10 | +using CodeBeam.UltimateAuth.Users.Contracts; |
| 11 | +using CodeBeam.UltimateAuth.Users.Reference; |
| 12 | +using Microsoft.EntityFrameworkCore; |
| 13 | +using Microsoft.Extensions.DependencyInjection; |
| 14 | +using Microsoft.Extensions.Logging.Abstractions; |
| 15 | +using Moq; |
| 16 | + |
| 17 | +namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Integration; |
| 18 | + |
| 19 | +public sealed class UserWorkflowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture> |
| 20 | +{ |
| 21 | + private static async Task<UserKey> RegisterAsync(IServiceProvider sp, LabDatabase db, string name) |
| 22 | + { |
| 23 | + var registration = new LabRegistrationService(ActorProvider.For(null), sp.GetRequiredService<IUserApplicationService>(), |
| 24 | + sp.GetRequiredService<IRoleStoreFactory>(), sp.GetRequiredService<IUserRoleStoreFactory>(), |
| 25 | + new(db), new(db, NullLogger<ActivityLogService>.Instance)); |
| 26 | + var result = await registration.RegisterAsync(new(name, $"{name}@example.test", "Owner-test-password-8!")); |
| 27 | + Assert.True(result.IsSuccess, result.ErrorMessage); |
| 28 | + await using var verify = db.CreateDbContext(); |
| 29 | + return (await verify.Sandboxes.SingleAsync(x => x.Name == $"{name}'s Sandbox")).OwnerUserKey; |
| 30 | + } |
| 31 | + |
| 32 | + private static LabUserProvisioningService Provisioning(IServiceProvider sp, LabDatabase db, UserKey owner) => |
| 33 | + new(ActorProvider.For(owner), new(db), new(db), sp.GetRequiredService<IUserApplicationService>(), |
| 34 | + sp.GetRequiredService<IRoleStoreFactory>(), sp.GetRequiredService<IUserRoleStoreFactory>(), |
| 35 | + sp.GetRequiredService<LabProvisioningRollbackGate>(), NullLogger<LabUserProvisioningService>.Instance); |
| 36 | + |
| 37 | + private static SandboxUserService UserService(IServiceProvider sp, LabDatabase db, UserKey owner, IUAuthClient client) => |
| 38 | + new(new(db), client, db, new(db), Provisioning(sp, db, owner), |
| 39 | + sp.GetRequiredService<IUserSummaryQueryStoreFactory>(), new(db, NullLogger<ActivityLogService>.Instance), |
| 40 | + sp.GetRequiredService<IRoleStoreFactory>(), sp.GetRequiredService<IUserRoleStoreFactory>(), |
| 41 | + NullLogger<SandboxUserService>.Instance); |
| 42 | + |
| 43 | + private static CreateUserRequest Request(string name) => new() |
| 44 | + { UserName = name, Email = $"{name}@example.test", Password = "Member-test-password-8!" }; |
| 45 | + |
| 46 | + [Theory] |
| 47 | + [InlineData(false)] |
| 48 | + [InlineData(true)] |
| 49 | + public async Task Created_member_is_queryable_capacity_rejection_leaves_no_account_and_deletion_frees_capacity(bool selfDelete) |
| 50 | + { |
| 51 | + await using var db = await postgres.CreateAsync(); |
| 52 | + await using var runtime = await LabRuntime.CreateAsync(db); |
| 53 | + await using var scope = runtime.Services.CreateAsyncScope(); |
| 54 | + var sp = scope.ServiceProvider; |
| 55 | + var owner = await RegisterAsync(sp, db, "owner"); |
| 56 | + var foreignOwner = await RegisterAsync(sp, db, "foreign"); |
| 57 | + var foreign = await Provisioning(sp, db, foreignOwner).CreateMemberAsync(foreignOwner, Request("foreign-member")); |
| 58 | + Assert.True(foreign.IsSuccess, foreign.FailureReason); |
| 59 | + var foreignKey = UserKey.FromString(foreign.UserKey!); |
| 60 | + await using (var setup = db.CreateDbContext()) |
| 61 | + { |
| 62 | + var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == owner); |
| 63 | + settings.MaxUsers = 2; |
| 64 | + await setup.SaveChangesAsync(); |
| 65 | + } |
| 66 | + var client = new Mock<IUAuthClient>(MockBehavior.Strict); |
| 67 | + var userClient = new Mock<IUserClient>(MockBehavior.Strict); |
| 68 | + client.SetupGet(x => x.Users).Returns(userClient.Object); |
| 69 | + var service = UserService(sp, db, owner, client.Object); |
| 70 | + await service.CreateAsync(owner, Request("member")); |
| 71 | + var visible = await service.GetUsersAsync(owner); |
| 72 | + Assert.NotNull(visible); |
| 73 | + var member = Assert.Single(visible.Users, x => !x.IsOwner).UserKey; |
| 74 | + Assert.Equal(2, visible.Users.Count); |
| 75 | + Assert.DoesNotContain(visible.Users, x => x.UserKey == foreignKey); |
| 76 | + var beforeRejected = await db.SnapshotAsync(); |
| 77 | + // The facade reports failures; assert persistence rather than dialog wording. |
| 78 | + await service.CreateAsync(owner, Request("rejected-member")); |
| 79 | + Assert.Equal(beforeRejected, await db.SnapshotAsync()); |
| 80 | + await using (var fresh = runtime.Services.CreateAsyncScope()) |
| 81 | + { |
| 82 | + var summaries = fresh.ServiceProvider.GetRequiredService<IUserSummaryQueryStoreFactory>().Create(TenantKeys.Single); |
| 83 | + var all = await summaries.QueryAsync(new UserQuery { PageSize = 100, IncludeDeleted = true }); |
| 84 | + Assert.DoesNotContain(all.Items, x => x.UserName == "rejected-member"); |
| 85 | + } |
| 86 | + Assert.False((await service.DeleteAsync(owner, foreignKey)).Success); |
| 87 | + Assert.Equal(beforeRejected, await db.SnapshotAsync()); |
| 88 | + userClient.VerifyNoOtherCalls(); |
| 89 | + var application = sp.GetRequiredService<IUserApplicationService>(); |
| 90 | + if (selfDelete) |
| 91 | + { |
| 92 | + userClient.Setup(x => x.DeleteMeAsync()).Returns(async () => |
| 93 | + { |
| 94 | + await application.DeleteMeAsync(Contexts.Create("users.delete.self", member, member)); |
| 95 | + return new UAuthResult { IsSuccess = true }; |
| 96 | + }); |
| 97 | + Assert.True(await new SandboxAccountDeletionService(db, client.Object, |
| 98 | + NullLogger<SandboxAccountDeletionService>.Instance).DeleteMemberAsync(member)); |
| 99 | + userClient.Verify(x => x.DeleteMeAsync(), Times.Once); |
| 100 | + } |
| 101 | + else |
| 102 | + { |
| 103 | + userClient.Setup(x => x.DeleteUserAsync(member, It.IsAny<DeleteUserRequest>())).Returns(async (UserKey target, DeleteUserRequest request) => |
| 104 | + { |
| 105 | + Assert.Equal(DeleteMode.Hard, request.Mode); |
| 106 | + var result = await application.DeleteUserAsync(Contexts.Create("users.delete.admin", owner, target), request); |
| 107 | + return new UAuthResult<UserDeleteResult> { IsSuccess = result.IsSuccess, Value = result }; |
| 108 | + }); |
| 109 | + Assert.True((await service.DeleteAsync(owner, member)).Success); |
| 110 | + userClient.Verify(x => x.DeleteUserAsync(member, It.IsAny<DeleteUserRequest>()), Times.Once); |
| 111 | + } |
| 112 | + await using var verification = runtime.Services.CreateAsyncScope(); |
| 113 | + var lifecycleStore = verification.ServiceProvider.GetRequiredService<IUserLifecycleStoreFactory>().Create(TenantKeys.Single); |
| 114 | + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, member)); |
| 115 | + if (selfDelete) { Assert.NotNull(lifecycle); Assert.True(lifecycle.IsDeleted); } |
| 116 | + else Assert.Null(lifecycle); |
| 117 | + Assert.NotNull(await lifecycleStore.GetAsync(new UserLifecycleKey(TenantKeys.Single, foreignKey))); |
| 118 | + var remaining = await service.GetUsersAsync(owner); |
| 119 | + Assert.NotNull(remaining); |
| 120 | + Assert.Equal(owner, Assert.Single(remaining.Users).UserKey); |
| 121 | + // Optional dashboard calls may fail; its persisted count must still be usable. |
| 122 | + var dashboard = await new LabDashboardService(new(db), client.Object).GetAsync(owner); |
| 123 | + Assert.NotNull(dashboard); |
| 124 | + Assert.Equal(1, dashboard.MemberCount); |
| 125 | + Assert.Equal(1, dashboard.RemainingCapacity); |
| 126 | + await service.CreateAsync(owner, Request("replacement")); |
| 127 | + Assert.Equal(2, (await service.GetUsersAsync(owner))!.Users.Count); |
| 128 | + await using var final = db.CreateDbContext(); |
| 129 | + Assert.False(await final.SandboxMembers.AnyAsync(x => x.UserKey == member)); |
| 130 | + Assert.Equal(2, await final.SandboxMembers.CountAsync(x => x.Sandbox.OwnerUserKey == foreignOwner)); |
| 131 | + } |
| 132 | + [Theory] |
| 133 | + [InlineData(false)] |
| 134 | + [InlineData(true)] |
| 135 | + public async Task Owner_and_admin_recovery_restore_missing_membership_without_duplicate_or_foreign_changes(bool adminRepair) |
| 136 | + { |
| 137 | + await using var db = await postgres.CreateAsync(); |
| 138 | + await using var runtime = await LabRuntime.CreateAsync(db); |
| 139 | + await using var scope = runtime.Services.CreateAsyncScope(); |
| 140 | + var sp = scope.ServiceProvider; |
| 141 | + var owner = await RegisterAsync(sp, db, "repair-owner"); |
| 142 | + var other = await RegisterAsync(sp, db, "other-owner"); |
| 143 | + var member = await Provisioning(sp, db, owner).CreateMemberAsync(owner, Request("retained-member")); |
| 144 | + Assert.True(member.IsSuccess, member.FailureReason); |
| 145 | + var roles = sp.GetRequiredService<IRoleStoreFactory>(); |
| 146 | + var assignments = sp.GetRequiredService<IUserRoleStoreFactory>(); |
| 147 | + await using (var setup = db.CreateDbContext()) |
| 148 | + { |
| 149 | + setup.SandboxMembers.Remove(await setup.SandboxMembers.SingleAsync(x => x.UserKey == owner)); |
| 150 | + await setup.SaveChangesAsync(); |
| 151 | + } |
| 152 | + if (adminRepair) |
| 153 | + { |
| 154 | + var context = Contexts.Create("identitylab.repair", LabAdminIdentity.UserKey); |
| 155 | + var service = new LabRepairService(db, sp.GetRequiredService<IUserSummaryQueryStoreFactory>(), |
| 156 | + roles, assignments, new LabAdminGuard(roles, assignments), NullLogger<LabRepairService>.Instance); |
| 157 | + var snapshot = await service.GetSnapshotAsync(context); |
| 158 | + Assert.True(snapshot.UserScanComplete); |
| 159 | + Assert.Contains(snapshot.OrphanUsers, x => x.UserKey == owner && x.OwnedSandboxId != null); |
| 160 | + Assert.DoesNotContain(snapshot.OrphanUsers, x => x.UserKey == other); |
| 161 | + Assert.True((await service.RepairOwnerAsync(context, owner)).Success); |
| 162 | + var repaired = await db.SnapshotAsync(); |
| 163 | + Assert.False((await service.RepairOwnerAsync(context, owner)).Success); |
| 164 | + Assert.Equal(repaired, await db.SnapshotAsync()); |
| 165 | + Assert.DoesNotContain((await service.GetSnapshotAsync(context)).OrphanUsers, x => x.UserKey == owner); |
| 166 | + } |
| 167 | + else |
| 168 | + { |
| 169 | + var context = Contexts.Create("identitylab.repair", owner); |
| 170 | + var service = new LabOwnerRepairService(db, sp.GetRequiredService<IUserLifecycleStoreFactory>(), |
| 171 | + roles, assignments, NullLogger<LabOwnerRepairService>.Instance); |
| 172 | + Assert.Equal(OwnerRepairState.CanRepair, (await service.GetStatusAsync(context)).State); |
| 173 | + Assert.True((await service.RepairAsync(context)).Success); |
| 174 | + var repaired = await db.SnapshotAsync(); |
| 175 | + Assert.Equal(OwnerRepairState.NotRequired, (await service.GetStatusAsync(context)).State); |
| 176 | + Assert.False((await service.RepairAsync(context)).Success); |
| 177 | + Assert.Equal(repaired, await db.SnapshotAsync()); |
| 178 | + } |
| 179 | + await using var final = db.CreateDbContext(); |
| 180 | + var members = await final.SandboxMembers.ToListAsync(); |
| 181 | + Assert.Equal(3, members.Count); |
| 182 | + var restored = Assert.Single(members, x => x.UserKey == owner); |
| 183 | + Assert.Equal(CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMemberRole.Owner, restored.Role); |
| 184 | + Assert.Contains(members, x => x.UserKey.Value == member.UserKey && x.SandboxId == restored.SandboxId); |
| 185 | + Assert.Contains(members, x => x.UserKey == other && x.SandboxId != restored.SandboxId); |
| 186 | + Assert.Equal(2, await final.Sandboxes.CountAsync()); |
| 187 | + } |
| 188 | +} |
0 commit comments