build-flow.api/tests/BuildFlow.Platform.Tests/Auth/LogoutRevokeAccessCodesHandlerTests.cs

225 lines
8.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// [SOP-PLATFORM] Auth 模块 3 个 Handler 覆盖测试。
/// 覆盖: Logout / RevokeToken / GetAccessCodes。
/// 飞书矩阵: platform/Auth 子域(3 Handler 零覆盖→全补)。
/// </summary>
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<IPermissionVersionStore>();
store.GetCurrentAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns(version);
return store;
}
private static IAccessCodeCache CreateCache()
{
var cache = Substitute.For<IAccessCodeCache>();
cache.GetAsync(Arg.Any<Guid>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<string>?)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<UnauthorizedException>();
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<IReadOnlyList<string>>(codes => codes.Contains("acc:read")),
Arg.Any<CancellationToken>());
}
[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<CancellationToken>())
.Returns(new List<string> { "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<Guid>(), Arg.Is<int>(v => v == 1), Arg.Any<CancellationToken>())
.Returns(new List<string> { "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<UnauthorizedException>();
ex.Which.Code.Should().Be(ErrorCode.Unauthorized);
}
#endregion
}