using BuildFlow.Platform.Application.Auth.Commands; using BuildFlow.Platform.Application.Auth.Handlers; using BuildFlow.Platform.Application.Auth.Queries; using BuildFlow.Platform.Application.Permissions.Commands; using BuildFlow.Platform.Application.Permissions.Handlers; using BuildFlow.Platform.Application.Roles.Commands; using BuildFlow.Platform.Application.Roles.Handlers; using BuildFlow.Platform.Domain.Common; using BuildFlow.Platform.Domain.Entities.RefreshToken; using BuildFlow.Platform.Domain.Entities.RolePermission; using BuildFlow.Platform.Domain.Entities.UserRole; using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Shared.Domain.Errors; using BuildFlow.Shared.Domain.Exceptions; using FluentAssertions; using Microsoft.EntityFrameworkCore; using NSubstitute; using Xunit; namespace BuildFlow.Platform.Tests.Auth; /// /// [SOP-PLATFORM] Auth 模块 3 个 Handler 覆盖测试。 /// 覆盖: Logout / RevokeToken / GetAccessCodes。 /// 飞书矩阵: platform/Auth 子域(3 Handler 零覆盖→全补)。 /// public class LogoutRevokeAccessCodesHandlerTests { private static PlatformDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "") => PlatformTestDb.CreateDbContext(name); private static IPermissionVersionStore CreateVersionStore(int version = 1) { var store = Substitute.For(); store.GetCurrentAsync(Arg.Any(), Arg.Any()).Returns(version); return store; } private static IAccessCodeCache CreateCache() { var cache = Substitute.For(); cache.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((IReadOnlyList?)null); return cache; } private static async Task SeedRefreshToken(PlatformDbContext db, string token) { var user = await PlatformTestDb.SeedUserAsync(db, Guid.NewGuid(), $"u-{Guid.NewGuid():N}"); var rt = PlatformTestDb.WithAudit(new RefreshTokenEntity { UserId = user.Id, User = user, Token = token, ExpiresAt = DateTime.UtcNow.AddDays(1), }); db.RefreshTokens.Add(rt); await db.SaveChangesAsync(); } #region Logout [Fact] public async Task Logout_WithExistingToken_RevokesIt() { var db = CreateDb(); await SeedRefreshToken(db, "tok-logout"); await new LogoutCommandHandler(db).Handle(new LogoutCommand("tok-logout"), CancellationToken.None); (await db.RefreshTokens.FirstAsync(r => r.Token == "tok-logout")).IsRevoked.Should().BeTrue(); } [Fact] public async Task Logout_WithEmptyToken_NoOp() { var db = CreateDb(); var act = async () => await new LogoutCommandHandler(db).Handle(new LogoutCommand(""), CancellationToken.None); await act.Should().NotThrowAsync(); } [Fact] public async Task Logout_WithUnknownToken_NoOp() { var db = CreateDb(); var act = async () => await new LogoutCommandHandler(db).Handle(new LogoutCommand("ghost"), CancellationToken.None); await act.Should().NotThrowAsync(); } #endregion #region RevokeToken [Fact] public async Task Revoke_ExistingToken_RevokesIt() { var db = CreateDb(); await SeedRefreshToken(db, "tok-revoke"); await new RevokeTokenCommandHandler(db).Handle(new RevokeTokenCommand("tok-revoke"), CancellationToken.None); (await db.RefreshTokens.FirstAsync(r => r.Token == "tok-revoke")).IsRevoked.Should().BeTrue(); } [Fact] public async Task Revoke_UnknownToken_ThrowsUnauthorized() { var db = CreateDb(); var act = async () => await new RevokeTokenCommandHandler(db).Handle(new RevokeTokenCommand("ghost"), CancellationToken.None); var ex = await act.Should().ThrowAsync(); ex.Which.Code.Should().Be(ErrorCode.Unauthorized); } #endregion #region GetAccessCodes [Fact] public async Task GetAccessCodes_ExistingUserWithRole_ReturnsCodes() { var db = CreateDb(); var user = await PlatformTestDb.SeedUserAsync(db, Guid.NewGuid(), $"acc-{Guid.NewGuid():N}"); var perm = await new CreatePermissionCommandHandler(db) .Handle(new CreatePermissionCommand("读", "acc:read", null, null), CancellationToken.None); var role = await new CreateRoleCommandHandler(db) .Handle(new CreateRoleCommand("AccRole", null), CancellationToken.None); var roleEntity = await db.Roles.FirstAsync(r => r.Id == role.Id); var permEntity = await db.Permissions.FirstAsync(p => p.Id == perm.Id); db.Add(PlatformTestDb.WithAudit(new UserRoleEntity { UserId = user.Id, RoleId = role.Id, User = user, Role = roleEntity, })); db.Add(PlatformTestDb.WithAudit(new RolePermissionEntity { RoleId = role.Id, PermissionId = perm.Id, Role = roleEntity, Permission = permEntity, })); await db.SaveChangesAsync(); var cache = CreateCache(); var result = await new GetAccessCodesQueryHandler(db, CreateVersionStore(), cache) .Handle(new GetAccessCodesQuery(user.Id.ToString()), CancellationToken.None); result.Should().Contain("acc:read"); await cache.Received(1).SetAsync( user.Id, 1, Arg.Is>(codes => codes.Contains("acc:read")), Arg.Any()); } [Fact] public async Task GetAccessCodes_CacheHit_ReturnsCachedCodesWithoutTouchingRoles() { var db = CreateDb(); var user = await PlatformTestDb.SeedUserAsync(db, Guid.NewGuid(), $"acc3-{Guid.NewGuid():N}"); // 数据库里的真实权限码与缓存不同:命中缓存时应原样返回缓存值 var cache = CreateCache(); cache.GetAsync(user.Id, 1, Arg.Any()) .Returns(new List { "cached:only" }); var result = await new GetAccessCodesQueryHandler(db, CreateVersionStore(), cache) .Handle(new GetAccessCodesQuery(user.Id.ToString()), CancellationToken.None); result.Should().BeEquivalentTo(["cached:only"]); } [Fact] public async Task GetAccessCodes_VersionBumped_IgnoresStaleVersionCache() { var db = CreateDb(); var user = await PlatformTestDb.SeedUserAsync(db, Guid.NewGuid(), $"acc4-{Guid.NewGuid():N}"); var perm = await new CreatePermissionCommandHandler(db) .Handle(new CreatePermissionCommand("写", "acc4:write", null, null), CancellationToken.None); var role = await new CreateRoleCommandHandler(db) .Handle(new CreateRoleCommand("AccRole4", null), CancellationToken.None); var roleEntity = await db.Roles.FirstAsync(r => r.Id == role.Id); var permEntity = await db.Permissions.FirstAsync(p => p.Id == perm.Id); db.Add(PlatformTestDb.WithAudit(new UserRoleEntity { UserId = user.Id, RoleId = role.Id, User = user, Role = roleEntity, })); db.Add(PlatformTestDb.WithAudit(new RolePermissionEntity { RoleId = role.Id, PermissionId = perm.Id, Role = roleEntity, Permission = permEntity, })); await db.SaveChangesAsync(); // 旧版本号 1 的缓存已过期,当前版本号是 2:必须忽略旧键、回库取新数据 var cache = CreateCache(); cache.GetAsync(Arg.Any(), Arg.Is(v => v == 1), Arg.Any()) .Returns(new List { "stale:code" }); var result = await new GetAccessCodesQueryHandler(db, CreateVersionStore(2), cache) .Handle(new GetAccessCodesQuery(user.Id.ToString()), CancellationToken.None); result.Should().Contain("acc4:write").And.NotContain("stale:code"); } [Fact] public async Task GetAccessCodes_UserWithoutRoles_ReturnsEmpty() { var db = CreateDb(); var user = await PlatformTestDb.SeedUserAsync(db, Guid.NewGuid(), $"acc2-{Guid.NewGuid():N}"); var result = await new GetAccessCodesQueryHandler(db, CreateVersionStore(), CreateCache()) .Handle(new GetAccessCodesQuery(user.Id.ToString()), CancellationToken.None); result.Should().BeEmpty(); } [Fact] public async Task GetAccessCodes_UnknownUser_ThrowsUnauthorized() { var db = CreateDb(); var act = async () => await new GetAccessCodesQueryHandler(db, CreateVersionStore(), CreateCache()) .Handle(new GetAccessCodesQuery(Guid.NewGuid().ToString()), CancellationToken.None); var ex = await act.Should().ThrowAsync(); ex.Which.Code.Should().Be(ErrorCode.Unauthorized); } #endregion }