Skip to content

Commit e22629c

Browse files
beyondnetPeruclaude
andcommitted
feat(ums): implement consistent state-change and deletion dependency guard policy
Adds a cross-cutting policy that blocks state-change and deletion operations when active dependencies exist, preventing orphaned records and inconsistencies. Domain layer: - BlockingDependency.cs: new record type and BlockedOperationError encoder/decoder for structured error propagation from application to presentation layer - DomainErrors: new guard error codes (TENANT_HAS_ACTIVE_USERS, USER_HAS_ACTIVE_PROFILES, ROLE_HAS_ACTIVE_PROFILES, ROLE_HAS_ACTIVE_CHILD_ROLES, TEMPLATE_HAS_ACTIVE_PROFILES, DOMAIN_RESOURCE_HAS_TEMPLATE_ITEMS, MODULE_HAS_ACTIVE_MENUS) Repository contracts: - IUserAccountRepository: CountActiveByTenantAsync - IProfileRepository: CountActiveByRoleAsync, CountActiveByTemplateAsync, CountActiveByUserAsync - IRoleRepository: CountActiveChildRolesAsync - IPermissionTemplateRepository: CountPublishedByRoleAsync, CountItemsByTargetAsync - Implemented in both SqlServer and InMemory repositories Application guards (pre-condition checks before state changes): - SuspendTenantCommandHandler: blocks if tenant has active users - BlockUserAccountCommandHandler: blocks if user has active profiles - DeleteUserAccountCommandHandler: blocks if user has active profiles - SetRoleStatusCommandHandler: blocks deactivation if role has active profiles or child roles - DeprecatePermissionTemplateCommandHandler: blocks if template has active profiles - RemoveDomainResourceCommandHandler: blocks if domain resource is referenced in template items Presentation layer: - BlockedOperationResponse: structured 409 Conflict response with errorCode, message, brokenRule, and blockingDependencies array - BlockedOperationMessages: user-facing messages and rules per error code - ResultExtensions.ToNoContent: routes through ToBlockedOrProblem for structured 409 vs generic 4xx Tests: 549 passing (+10 new guard tests in RoleCommandHandlerTests and SuspendTenantCommandHandlerTests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b4294be commit e22629c

22 files changed

Lines changed: 535 additions & 6 deletions

src/apps/ums.api/Ums.Application.Test/Authorization/Role/RoleCommandHandlerTests.cs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ namespace Ums.Application.Test.Authorization.Role;
44
using Ums.Application.Common.Interfaces;
55
using Ums.Domain.Authorization;
66
using Ums.Domain.Authorization.SystemSuite;
7+
using Ums.Domain.Kernel;
78
using RoleAggregate = Ums.Domain.Authorization.Role.Role;
89
using Moq;
910
using Xunit;
1011

1112
public sealed class RoleCommandHandlerTests
1213
{
1314
private readonly Mock<IRoleRepository> _roles = new();
15+
private readonly Mock<IProfileRepository> _profiles = new();
1416
private readonly Mock<ISystemSuiteRepository> _suites = new();
1517
private readonly Mock<IUnitOfWork> _unitOfWork = new();
1618
private readonly Mock<IUserContext> _userContext = new();
@@ -23,8 +25,17 @@ public RoleCommandHandlerTests()
2325
_userContext.Setup(x => x.UserId).Returns("user-001");
2426
_scopePolicy.Setup(x => x.EnsureManagementOwnerScopeAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
2527
.ReturnsAsync(Result.Success());
28+
29+
// Default: no active profiles or child roles → guard passes
30+
_profiles.Setup(x => x.CountActiveByRoleAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
31+
.ReturnsAsync(0);
32+
_roles.Setup(x => x.CountActiveChildRolesAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
33+
.ReturnsAsync(0);
2634
}
2735

36+
private SetRoleStatusCommandHandler MakeSetStatusHandler()
37+
=> new(_roles.Object, _profiles.Object, _userContext.Object);
38+
2839
private static SystemSuite MakeSuite() => SystemSuite.Create(
2940
TenantId.Load(Guid.NewGuid()),
3041
Code.Create("WMS"),
@@ -99,7 +110,7 @@ public async Task SetStatus_Deactivate_UpdatesRole()
99110
suite.TenantId, suite.GetId(), Code.Create("OPERATOR"), Name.Create("Operator"),
100111
Description.Create(""), null, 0, 0, ActorId.Create("user-001")).Value;
101112
_roles.Setup(x => x.GetByIdAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>())).ReturnsAsync(role);
102-
var handler = new SetRoleStatusCommandHandler(_roles.Object, _userContext.Object);
113+
var handler = MakeSetStatusHandler();
103114

104115
var result = await handler.Handle(new SetRoleStatusCommand(role.GetId().GetValue(), false), CancellationToken.None);
105116

@@ -108,6 +119,69 @@ public async Task SetStatus_Deactivate_UpdatesRole()
108119
_roles.Verify(x => x.UpdateAsync(role, It.IsAny<CancellationToken>()), Times.Once);
109120
}
110121

122+
[Fact]
123+
public async Task SetStatus_Deactivate_WhenRoleHasActiveProfiles_ReturnsBlockedFailure()
124+
{
125+
var suite = MakeSuite();
126+
var role = RoleAggregate.Create(
127+
suite.TenantId, suite.GetId(), Code.Create("OPERATOR"), Name.Create("Operator"),
128+
Description.Create(""), null, 0, 0, ActorId.Create("user-001")).Value;
129+
_roles.Setup(x => x.GetByIdAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>())).ReturnsAsync(role);
130+
_profiles.Setup(x => x.CountActiveByRoleAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>()))
131+
.ReturnsAsync(5);
132+
var handler = MakeSetStatusHandler();
133+
134+
var result = await handler.Handle(new SetRoleStatusCommand(role.GetId().GetValue(), false), CancellationToken.None);
135+
136+
Assert.True(result.IsFailure);
137+
var decoded = BlockedOperationError.TryDecode(result.Error, out var code, out var deps);
138+
Assert.True(decoded);
139+
Assert.Equal(DomainErrors.Authorization.RoleHasActiveProfiles, code);
140+
Assert.Single(deps);
141+
Assert.Equal("Profile", deps[0].EntityType);
142+
Assert.Equal(5, deps[0].Count);
143+
}
144+
145+
[Fact]
146+
public async Task SetStatus_Deactivate_WhenRoleHasActiveChildRoles_ReturnsBlockedFailure()
147+
{
148+
var suite = MakeSuite();
149+
var role = RoleAggregate.Create(
150+
suite.TenantId, suite.GetId(), Code.Create("OPERATOR"), Name.Create("Operator"),
151+
Description.Create(""), null, 0, 0, ActorId.Create("user-001")).Value;
152+
_roles.Setup(x => x.GetByIdAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>())).ReturnsAsync(role);
153+
_roles.Setup(x => x.CountActiveChildRolesAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>()))
154+
.ReturnsAsync(3);
155+
var handler = MakeSetStatusHandler();
156+
157+
var result = await handler.Handle(new SetRoleStatusCommand(role.GetId().GetValue(), false), CancellationToken.None);
158+
159+
Assert.True(result.IsFailure);
160+
var decoded = BlockedOperationError.TryDecode(result.Error, out var code, out _);
161+
Assert.True(decoded);
162+
Assert.Equal(DomainErrors.Authorization.RoleHasActiveChildRoles, code);
163+
}
164+
165+
[Fact]
166+
public async Task SetStatus_Activate_DoesNotCheckDependencies()
167+
{
168+
var suite = MakeSuite();
169+
var role = RoleAggregate.Create(
170+
suite.TenantId, suite.GetId(), Code.Create("OPERATOR"), Name.Create("Operator"),
171+
Description.Create(""), null, 0, 0, ActorId.Create("user-001")).Value;
172+
role.Deactivate(ActorId.Create("user-001"));
173+
_roles.Setup(x => x.GetByIdAsync(role.GetId().GetValue(), It.IsAny<CancellationToken>())).ReturnsAsync(role);
174+
// Setup unreachable: profiles have many active — should NOT be checked on activate
175+
_profiles.Setup(x => x.CountActiveByRoleAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
176+
.ReturnsAsync(99);
177+
var handler = MakeSetStatusHandler();
178+
179+
var result = await handler.Handle(new SetRoleStatusCommand(role.GetId().GetValue(), true), CancellationToken.None);
180+
181+
Assert.True(result.IsSuccess);
182+
_profiles.Verify(x => x.CountActiveByRoleAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
183+
}
184+
111185
[Fact]
112186
public async Task Update_WhenParentIsDescendant_RejectsCycle()
113187
{

src/apps/ums.api/Ums.Application.Test/Tenants/SuspendTenant/SuspendTenantCommandHandlerTests.cs

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace Ums.Application.Test.Tenants.SuspendTenant;
1+
namespace Ums.Application.Test.Tenants.SuspendTenant;
22

33
using Ums.Application.Identity.Tenant.Commands;
44
using Ums.Domain.Identity;
@@ -9,17 +9,28 @@
99
public class SuspendTenantCommandHandlerTests
1010
{
1111
private readonly Mock<ITenantRepository> _tenantRepositoryMock;
12+
private readonly Mock<IUserAccountRepository> _userAccountRepositoryMock;
1213
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
1314
private readonly Mock<IUserContext> _userContextMock;
1415
private readonly SuspendTenantCommandHandler _handler;
1516

1617
public SuspendTenantCommandHandlerTests()
1718
{
1819
_tenantRepositoryMock = new Mock<ITenantRepository>();
20+
_userAccountRepositoryMock = new Mock<IUserAccountRepository>();
1921
_unitOfWorkMock = new Mock<IUnitOfWork>();
2022
_tenantRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object);
2123
_userContextMock = new Mock<IUserContext>();
22-
_handler = new SuspendTenantCommandHandler(_tenantRepositoryMock.Object, _userContextMock.Object);
24+
25+
// Default: no active users → guard passes
26+
_userAccountRepositoryMock
27+
.Setup(r => r.CountActiveByTenantAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
28+
.ReturnsAsync(0);
29+
30+
_handler = new SuspendTenantCommandHandler(
31+
_tenantRepositoryMock.Object,
32+
_userAccountRepositoryMock.Object,
33+
_userContextMock.Object);
2334
}
2435

2536
#region Handle - Success Scenarios
@@ -126,6 +137,86 @@ public async Task Handle_WhenTenantAlreadySuspended_ReturnsFailure()
126137
Assert.True(result.IsFailure);
127138
}
128139

140+
// ── Dependency guard tests ────────────────────────────────────────────────
141+
142+
[Fact]
143+
public async Task Handle_WhenTenantHasActiveUsers_ReturnsBlockedFailure()
144+
{
145+
var tenantId = Guid.NewGuid();
146+
_userContextMock.Setup(u => u.UserId).Returns("user-001");
147+
var tenant = CreateActiveTenant();
148+
_tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny<CancellationToken>()))
149+
.ReturnsAsync(tenant);
150+
151+
_userAccountRepositoryMock
152+
.Setup(r => r.CountActiveByTenantAsync(tenantId, It.IsAny<CancellationToken>()))
153+
.ReturnsAsync(12);
154+
155+
var result = await _handler.Handle(new SuspendTenantCommand(tenantId), CancellationToken.None);
156+
157+
Assert.True(result.IsFailure);
158+
Assert.Contains(DomainErrors.Tenant.HasActiveUsers, result.Error);
159+
}
160+
161+
[Fact]
162+
public async Task Handle_WhenTenantHasActiveUsers_DoesNotUpdateTenant()
163+
{
164+
var tenantId = Guid.NewGuid();
165+
_userContextMock.Setup(u => u.UserId).Returns("user-001");
166+
var tenant = CreateActiveTenant();
167+
_tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny<CancellationToken>()))
168+
.ReturnsAsync(tenant);
169+
170+
_userAccountRepositoryMock
171+
.Setup(r => r.CountActiveByTenantAsync(tenantId, It.IsAny<CancellationToken>()))
172+
.ReturnsAsync(5);
173+
174+
await _handler.Handle(new SuspendTenantCommand(tenantId), CancellationToken.None);
175+
176+
_tenantRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny<Tenant>(), It.IsAny<CancellationToken>()), Times.Never);
177+
}
178+
179+
[Fact]
180+
public async Task Handle_WhenTenantHasActiveUsers_ErrorContainsBlockingDependencies()
181+
{
182+
var tenantId = Guid.NewGuid();
183+
_userContextMock.Setup(u => u.UserId).Returns("user-001");
184+
var tenant = CreateActiveTenant();
185+
_tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny<CancellationToken>()))
186+
.ReturnsAsync(tenant);
187+
188+
_userAccountRepositoryMock
189+
.Setup(r => r.CountActiveByTenantAsync(tenantId, It.IsAny<CancellationToken>()))
190+
.ReturnsAsync(3);
191+
192+
var result = await _handler.Handle(new SuspendTenantCommand(tenantId), CancellationToken.None);
193+
194+
var decoded = BlockedOperationError.TryDecode(result.Error, out var code, out var deps);
195+
Assert.True(decoded);
196+
Assert.Equal(DomainErrors.Tenant.HasActiveUsers, code);
197+
Assert.Single(deps);
198+
Assert.Equal("UserAccount", deps[0].EntityType);
199+
Assert.Equal(3, deps[0].Count);
200+
}
201+
202+
[Fact]
203+
public async Task Handle_WhenTenantHasNoActiveUsers_AllowsSuspension()
204+
{
205+
var tenantId = Guid.NewGuid();
206+
_userContextMock.Setup(u => u.UserId).Returns("user-001");
207+
var tenant = CreateActiveTenant();
208+
_tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny<CancellationToken>()))
209+
.ReturnsAsync(tenant);
210+
211+
_userAccountRepositoryMock
212+
.Setup(r => r.CountActiveByTenantAsync(tenantId, It.IsAny<CancellationToken>()))
213+
.ReturnsAsync(0);
214+
215+
var result = await _handler.Handle(new SuspendTenantCommand(tenantId), CancellationToken.None);
216+
217+
Assert.True(result.IsSuccess);
218+
}
219+
129220
#endregion
130221

131222
private static Tenant CreateActiveTenant()

src/apps/ums.api/Ums.Application/Authorization/Role/Commands/SetRoleStatusCommandHandler.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
using Ums.Domain.Authorization;
2+
using Ums.Domain.Kernel;
23

34
namespace Ums.Application.Authorization.Role.Commands;
45

56
public sealed class SetRoleStatusCommandHandler : ICommandHandler<SetRoleStatusCommand>
67
{
78
private readonly IRoleRepository _repository;
9+
private readonly IProfileRepository _profileRepository;
810
private readonly IUserContext _userContext;
911

10-
public SetRoleStatusCommandHandler(IRoleRepository repository, IUserContext userContext)
12+
public SetRoleStatusCommandHandler(
13+
IRoleRepository repository,
14+
IProfileRepository profileRepository,
15+
IUserContext userContext)
1116
{
1217
_repository = repository;
18+
_profileRepository = profileRepository;
1319
_userContext = userContext;
1420
}
1521

@@ -28,6 +34,31 @@ public async Task<Result> Handle(SetRoleStatusCommand request, CancellationToken
2834
return Result.Failure("No se pudo cambiar el estado porque el rol no existe.");
2935
}
3036

37+
// ── Dependency guard (deactivate only) ────────────────────────────────
38+
if (!request.IsActive)
39+
{
40+
var activeProfileCount = await _profileRepository.CountActiveByRoleAsync(
41+
request.RoleId, cancellationToken);
42+
43+
var activeChildCount = await _repository.CountActiveChildRolesAsync(
44+
request.RoleId, cancellationToken);
45+
46+
if (activeProfileCount > 0 || activeChildCount > 0)
47+
{
48+
var deps = new List<BlockingDependency>();
49+
if (activeProfileCount > 0)
50+
deps.Add(new("Profile", "Active", activeProfileCount));
51+
if (activeChildCount > 0)
52+
deps.Add(new("Role", "Active", activeChildCount));
53+
54+
var errorCode = activeProfileCount > 0
55+
? DomainErrors.Authorization.RoleHasActiveProfiles
56+
: DomainErrors.Authorization.RoleHasActiveChildRoles;
57+
58+
return Result.Failure(BlockedOperationError.Encode(errorCode, deps));
59+
}
60+
}
61+
3162
var actor = ActorId.Create(_userContext.UserId);
3263
var result = request.IsActive ? role.Activate(actor) : role.Deactivate(actor);
3364
if (result.IsFailure)

src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveDomainResourceCommandHandler.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
namespace Ums.Application.Authorization.SystemSuite.Commands;
1010

11-
public sealed class RemoveDomainResourceCommandHandler(ISystemSuiteRepository repository, IUserContext userContext)
11+
public sealed class RemoveDomainResourceCommandHandler(
12+
ISystemSuiteRepository repository,
13+
IPermissionTemplateRepository templateRepository,
14+
IUserContext userContext)
1215
: ICommandHandler<RemoveDomainResourceCommand>
1316
{
1417
public async Task<Result> Handle(RemoveDomainResourceCommand request, CancellationToken cancellationToken)
@@ -19,6 +22,20 @@ public async Task<Result> Handle(RemoveDomainResourceCommand request, Cancellati
1922
return Result.Failure(DomainErrors.Common.NotFound);
2023
}
2124

25+
// ── Dependency guard: active template items referencing this resource ──
26+
var templateItemCount = await templateRepository.CountItemsByTargetAsync(
27+
request.DomainResourceId, cancellationToken);
28+
29+
if (templateItemCount > 0)
30+
{
31+
var deps = new List<BlockingDependency>
32+
{
33+
new("PermissionTemplateItem", "Active", templateItemCount),
34+
};
35+
return Result.Failure(BlockedOperationError.Encode(
36+
DomainErrors.Authorization.DomainResourceHasTemplateItems, deps));
37+
}
38+
2239
var result = suite.RemoveDomainResource(
2340
IdValueObject.Load(request.DomainResourceId),
2441
ActorId.Create(userContext.UserId ?? string.Empty));

src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeprecatePermissionTemplateCommandHandler.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
namespace Ums.Application.Authorization.Template.Commands;
22

33
using Ums.Domain.Authorization;
4+
using Ums.Domain.Kernel;
45

56
public sealed class DeprecatePermissionTemplateCommandHandler
67
: ICommandHandler<DeprecatePermissionTemplateCommand>
78
{
89
private readonly IPermissionTemplateRepository _repository;
10+
private readonly IProfileRepository _profileRepository;
911
private readonly IUserContext _userContext;
1012

1113
public DeprecatePermissionTemplateCommandHandler(
1214
IPermissionTemplateRepository repository,
15+
IProfileRepository profileRepository,
1316
IUserContext userContext)
1417
{
1518
_repository = repository;
19+
_profileRepository = profileRepository;
1620
_userContext = userContext;
1721
}
1822

@@ -28,6 +32,20 @@ public async Task<Result> Handle(
2832
var template = await _repository.GetByIdAsync(request.TemplateId, cancellationToken);
2933
if (template is null) return Result.Failure("Template not found.");
3034

35+
// ── Dependency guard: active profiles using this template ──────────────
36+
var activeProfileCount = await _profileRepository.CountActiveByTemplateAsync(
37+
request.TemplateId, cancellationToken);
38+
39+
if (activeProfileCount > 0)
40+
{
41+
var deps = new List<BlockingDependency>
42+
{
43+
new("Profile", "Active", activeProfileCount),
44+
};
45+
return Result.Failure(BlockedOperationError.Encode(
46+
DomainErrors.Authorization.TemplateHasActiveProfiles, deps));
47+
}
48+
3149
var result = template.Deprecate(ActorId.Create(_userContext.UserId));
3250
if (result.IsFailure) return result;
3351

0 commit comments

Comments
 (0)