Skip to content

Commit 9b998cf

Browse files
committed
test(identity-lab): add critical service workflow regression coverage
1 parent 7c66a0c commit 9b998cf

6 files changed

Lines changed: 532 additions & 0 deletions

File tree

‎tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Integration/SandboxPersistenceTests.cs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,40 @@ public async Task Concurrent_creation_cannot_create_two_sandboxes_for_one_owner(
9696
Assert.Equal(1, await verify.SandboxSettings.CountAsync());
9797
}
9898

99+
[Theory]
100+
[InlineData(true)]
101+
[InlineData(false)]
102+
public async Task Ensure_restores_owner_relationship_without_changing_existing_sandbox(bool missingMembership)
103+
{
104+
await using var db = await postgres.CreateAsync();
105+
await db.SeedAsync();
106+
var service = new SandboxService(db);
107+
var original = (await service.GetByUserAsync(LabDatabase.OwnerA))!;
108+
await using (var setup = db.CreateDbContext())
109+
{
110+
var owner = await setup.SandboxMembers.SingleAsync(x => x.UserKey == LabDatabase.OwnerA);
111+
if (missingMembership)
112+
setup.SandboxMembers.Remove(owner);
113+
else
114+
owner.Role = SandboxMemberRole.Member;
115+
await setup.SaveChangesAsync();
116+
}
117+
var repaired = await service.EnsureAsync(LabDatabase.OwnerA, "Must not replace existing name");
118+
Assert.Equal(original.Id, repaired.Id);
119+
Assert.Equal(original.Name, repaired.Name);
120+
Assert.Equal(original.Settings.MaxUsers, repaired.Settings.MaxUsers);
121+
var beforeRepeat = await db.SnapshotAsync();
122+
await service.EnsureAsync(LabDatabase.OwnerA, "Still must not rename");
123+
Assert.Equal(beforeRepeat, await db.SnapshotAsync());
124+
await using var verify = db.CreateDbContext();
125+
var members = await verify.SandboxMembers.ToListAsync();
126+
Assert.Equal(4, members.Count);
127+
Assert.Equal(SandboxMemberRole.Owner, Assert.Single(members, x => x.UserKey == LabDatabase.OwnerA).Role);
128+
Assert.Contains(members, x => x.UserKey == LabDatabase.MemberA && x.SandboxId == original.Id);
129+
Assert.Contains(members, x => x.UserKey == LabDatabase.OwnerB && x.Role == SandboxMemberRole.Owner);
130+
Assert.Contains(members, x => x.UserKey == LabDatabase.MemberB && x.SandboxId != original.Id);
131+
Assert.Equal(2, await verify.Sandboxes.CountAsync());
132+
Assert.Equal(2, await verify.SandboxSettings.CountAsync());
133+
}
134+
99135
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using CodeBeam.UltimateAuth.Client;
2+
using CodeBeam.UltimateAuth.Client.Services;
3+
using CodeBeam.UltimateAuth.Core.Domain;
4+
using CodeBeam.UltimateAuth.IdentityLab.Domain;
5+
using CodeBeam.UltimateAuth.IdentityLab.Services;
6+
using Microsoft.EntityFrameworkCore;
7+
using Moq;
8+
9+
namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Services;
10+
11+
public sealed class DashboardTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
12+
{
13+
[Theory]
14+
[InlineData(1, 1, 0)]
15+
[InlineData(5, 5, 3)]
16+
[InlineData(100, SandboxLimits.AbsoluteMaxUsers, 18)]
17+
public async Task Overview_uses_own_membership_and_effective_capacity_when_optional_services_fail(
18+
int configuredLimit, int expectedLimit, int expectedRemaining)
19+
{
20+
await using var db = await postgres.CreateAsync();
21+
await db.SeedAsync();
22+
await using (var setup = db.CreateDbContext())
23+
{
24+
var settings = await setup.SandboxSettings.SingleAsync(x => x.Sandbox.OwnerUserKey == LabDatabase.OwnerA);
25+
settings.MaxUsers = configuredLimit;
26+
await setup.SaveChangesAsync();
27+
}
28+
var users = new Mock<IUserClient>(MockBehavior.Strict);
29+
users.Setup(x => x.GetMeAsync(null)).ThrowsAsync(new InvalidOperationException("Profile unavailable"));
30+
var sessions = new Mock<ISessionClient>(MockBehavior.Strict);
31+
sessions.Setup(x => x.GetMyChainsAsync(It.IsAny<CodeBeam.UltimateAuth.Core.Contracts.PageRequest>()))
32+
.ThrowsAsync(new InvalidOperationException("Sessions unavailable"));
33+
var authorization = new Mock<IAuthorizationClient>(MockBehavior.Strict);
34+
authorization.Setup(x => x.GetMyRolesAsync(It.IsAny<CodeBeam.UltimateAuth.Core.Contracts.PageRequest>()))
35+
.ThrowsAsync(new InvalidOperationException("Roles unavailable"));
36+
var client = new Mock<IUAuthClient>(MockBehavior.Strict);
37+
client.SetupGet(x => x.Users).Returns(users.Object);
38+
client.SetupGet(x => x.Sessions).Returns(sessions.Object);
39+
client.SetupGet(x => x.Authorization).Returns(authorization.Object);
40+
var service = new LabDashboardService(new(db), client.Object);
41+
42+
var owner = await service.GetAsync(LabDatabase.OwnerA);
43+
Assert.NotNull(owner);
44+
Assert.Equal("Sandbox A", owner.SandboxName);
45+
Assert.True(owner.IsOwner);
46+
Assert.Equal(2, owner.MemberCount);
47+
Assert.Equal(expectedLimit, owner.MaxUsers);
48+
Assert.Equal(expectedRemaining, owner.RemainingCapacity);
49+
Assert.Null(owner.DisplayName);
50+
Assert.Null(owner.ChainCount);
51+
Assert.Null(owner.RoleCount);
52+
users.VerifyAll();
53+
sessions.VerifyAll();
54+
authorization.VerifyAll();
55+
56+
var member = await service.GetAsync(LabDatabase.MemberA);
57+
Assert.NotNull(member);
58+
Assert.Equal(owner.SandboxId, member.SandboxId);
59+
Assert.False(member.IsOwner);
60+
Assert.Equal(owner.MemberCount, member.MemberCount);
61+
62+
Assert.True(await new SandboxService(db).RemoveMemberAsync(LabDatabase.OwnerA, LabDatabase.MemberA));
63+
var after = await service.GetAsync(LabDatabase.OwnerA);
64+
Assert.NotNull(after);
65+
Assert.Equal(1, after.MemberCount);
66+
Assert.Equal(expectedLimit - 1, after.RemainingCapacity);
67+
}
68+
69+
[Fact]
70+
public async Task Missing_membership_returns_no_dashboard_without_requesting_optional_data()
71+
{
72+
await using var db = await postgres.CreateAsync();
73+
await db.SeedAsync();
74+
var client = new Mock<IUAuthClient>(MockBehavior.Strict);
75+
Assert.Null(await new LabDashboardService(new(db), client.Object).GetAsync(UserKey.FromString("orphan")));
76+
client.VerifyNoOtherCalls();
77+
}
78+
}

‎tests/CodeBeam.UltimateAuth.IdentityLab.Tests/Services/RepairTests.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,35 @@ public async Task Repair_removes_only_own_stale_members_and_preserves_both_owner
3030
Assert.Contains(LabDatabase.OwnerA, keys);
3131
Assert.Contains(LabDatabase.OwnerB, keys);
3232
}
33+
[Fact]
34+
public async Task Preview_matches_repair_and_repeated_repair_preserves_valid_and_unrelated_members()
35+
{
36+
await using var db = await postgres.CreateAsync();
37+
await db.SeedAsync();
38+
var validKey = CodeBeam.UltimateAuth.Core.Domain.UserKey.FromString("valid-member");
39+
await new SandboxService(db).AddMemberAsync(LabDatabase.OwnerA, validKey);
40+
var store = new Mock<IUserSummaryQueryStore>(MockBehavior.Strict);
41+
store.Setup(x => x.GetByKeysAsync(
42+
It.IsAny<IReadOnlyCollection<CodeBeam.UltimateAuth.Core.Domain.UserKey>>(), It.IsAny<CancellationToken>()))
43+
.ReturnsAsync(new[] { new UserSummary { UserKey = validKey } });
44+
var stores = new Mock<IUserSummaryQueryStoreFactory>();
45+
stores.Setup(x => x.Create(It.IsAny<TenantKey>())).Returns(store.Object);
46+
var service = new SandboxRepairService(db, stores.Object, NullLogger<SandboxRepairService>.Instance);
47+
var before = await db.SnapshotAsync();
48+
Assert.Equal(new SandboxRepairPreview(3, 1), await service.PreviewAsync(LabDatabase.OwnerA));
49+
Assert.Equal(before, await db.SnapshotAsync());
50+
Assert.Equal(1, (await service.RepairAsync(LabDatabase.OwnerA)).RemovedMemberships);
51+
var repaired = await db.SnapshotAsync();
52+
Assert.Equal(new SandboxRepairPreview(2, 0), await service.PreviewAsync(LabDatabase.OwnerA));
53+
Assert.Equal(0, (await service.RepairAsync(LabDatabase.OwnerA)).RemovedMemberships);
54+
Assert.Equal(repaired, await db.SnapshotAsync());
55+
await using var verify = db.CreateDbContext();
56+
var keys = await verify.SandboxMembers.Select(x => x.UserKey).ToListAsync();
57+
Assert.Equal(4, keys.Count);
58+
Assert.Contains(validKey, keys);
59+
Assert.Contains(LabDatabase.OwnerA, keys);
60+
Assert.Contains(LabDatabase.OwnerB, keys);
61+
Assert.Contains(LabDatabase.MemberB, keys);
62+
Assert.DoesNotContain(LabDatabase.MemberA, keys);
63+
}
3364
}

0 commit comments

Comments
 (0)