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
}