225 lines
8.9 KiB
C#
225 lines
8.9 KiB
C#
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
|
||
}
|