using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.TestHost; using MAX.Framework.DependencyInjection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using System.Security.Claims; using MAX.Framework.Security; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace Store.Users.Tests; public sealed class PersistentSessionTests { private sealed class Clock : TimeProvider { public DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => Now; } private static IFreeSql Open(string path) { var db = new FreeSql.FreeSqlBuilder() .UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute, FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi) .UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false").Build(); db.CodeFirst.ConfigEntity(e => e.Name("tokens")); db.CodeFirst.ConfigEntity(e => e.Name("users")); db.CodeFirst.SyncStructure(); db.Ado.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, user_name TEXT, nick_name TEXT)"); db.Ado.ExecuteNonQuery("INSERT OR IGNORE INTO users VALUES (1, 'same', 'Nickname')"); return db; } private static TokenResult Issue(TokenService service, string? device = null) => service.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10", device, "Device"); [Fact] public async Task BearerHandler_EnforcesDatabaseTokensAndReturns401And403() { using var db = Open(":memory:"); var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddSingleton(db); builder.Services.AddStoreHubTokenAuth(builder.Configuration); builder.Services.AddAuthorization( options => options.AddPolicy("deny", policy => policy.RequireClaim("missing"))); await using var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/private", () => "ok").RequireAuthorization(); app.MapGet("/forbidden", () => "ok").RequireAuthorization("deny"); await app.StartAsync(); using var client = app.GetTestClient(); Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode); var service = app.Services.GetRequiredService(); var token = Issue((TokenService)service); client.DefaultRequestHeaders.Authorization = new("Bearer", token.Token); Assert.Equal("ok", await client.GetStringAsync("/private")); Assert.Equal(System.Net.HttpStatusCode.Forbidden, (await client.GetAsync("/forbidden")).StatusCode); var principal = await service.ValidateTokenAsync(token.Token); var store = app.Services.GetRequiredService(); store.SignOutSession("same", principal!.FindFirst(JwtClaimTypes.Jti)!.Value); Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode); } [Fact] public async Task TokensSurviveRestart_AndLogoutInvalidatesWarmCache() { var path = Path.Combine(Path.GetTempPath(), $"tokens-{Guid.NewGuid():N}.db"); var clock = new Clock(); var options = Options.Create(new TokenAuthOptions()); try { TokenResult first, second; using (var db = Open(path)) using (var cache = new MemoryCache(new MemoryCacheOptions())) { var service = new TokenService(db, options, cache, clock); first = Issue(service); second = Issue(service); Assert.Equal(32, Convert.FromBase64String(first.Token).Length); Assert.NotEqual(first.Token, second.Token); Assert.All(db.Select().ToList(), x => Assert.Equal(64, x.TokenHash.Length)); } using (var db = Open(path)) using (var cache = new MemoryCache(new MemoryCacheOptions())) { var service = new TokenService(db, options, cache, clock); var store = new OnlineUserStore(db, service); var principal = await service.ValidateTokenAsync(first.Token); Assert.Equal("Nickname", principal!.FindFirst(JwtClaimTypes.NickName)!.Value); Assert.NotNull(await service.ValidateTokenAsync(second.Token)); var id = principal.FindFirst(JwtClaimTypes.Jti)!.Value; store.SignOutSession("other", id); Assert.NotNull(await service.ValidateTokenAsync(first.Token)); store.SignOutSession("same", id); Assert.Null(await service.ValidateTokenAsync(first.Token)); Assert.NotNull(await service.ValidateTokenAsync(second.Token)); Assert.Single(store.GetOnlineUsers()); store.SignOut("same"); Assert.Null(await service.ValidateTokenAsync(second.Token)); Assert.All(db.Select().ToList(), t => Assert.True(t.Revoked)); } } finally { File.Delete(path); } } [Fact] public async Task SlidingRenewal_IsThrottled_AndCappedByAbsoluteExpiry() { using var db = Open(":memory:"); using var cache = new MemoryCache(new MemoryCacheOptions()); var clock = new Clock(); var service = new TokenService(db, Options.Create(new TokenAuthOptions { IdleDays = 2, AbsoluteMaxDays = 3 }), cache, clock); var token = Issue(service); var initial = db.Select().First(); clock.Now = clock.Now.AddMinutes(9); Assert.NotNull(await service.ValidateTokenAsync(token.Token)); Assert.Equal(initial.ExpiresAt, db.Select().First().ExpiresAt); clock.Now = clock.Now.AddMinutes(1); Assert.NotNull(await service.ValidateTokenAsync(token.Token)); Assert.Equal(clock.Now.UtcDateTime.AddDays(2), db.Select().First().ExpiresAt); clock.Now = clock.Now.AddDays(1); Assert.NotNull(await service.ValidateTokenAsync(token.Token)); Assert.Equal(initial.CreatedAt.AddDays(3), db.Select().First().ExpiresAt); clock.Now = new DateTimeOffset(initial.CreatedAt.AddDays(3), TimeSpan.Zero); Assert.Null(await service.ValidateTokenAsync(token.Token)); } [Theory] [InlineData(0, 3)] [InlineData(1, 1)] [InlineData(2, 2)] public async Task DeviceLimit_EvictsOldestAndClearsCache(int limit, int expected) { using var db = Open(":memory:"); using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new TokenService(db, Options.Create(new TokenAuthOptions { MaxDevicesPerUser = limit }), cache, new Clock()); var first = Issue(service, "a"); Assert.NotNull(await service.ValidateTokenAsync(first.Token)); Issue(service, "b"); var last = Issue(service, "c"); Assert.Equal(expected, db.Select().Where(t => !t.Revoked).Count()); Assert.NotNull(await service.ValidateTokenAsync(last.Token)); Assert.Equal(limit == 0, await service.ValidateTokenAsync(first.Token) != null); var replacement = Issue(service, "c"); Assert.Null(await service.ValidateTokenAsync(last.Token)); Assert.NotNull(await service.ValidateTokenAsync(replacement.Token)); } [Fact] public async Task Cleanup_RemovesRevokedAndExpiredTokens_AndPreservesActiveTokens() { using var db = Open(":memory:"); using var cache = new MemoryCache(new MemoryCacheOptions()); var clock = new Clock(); var service = new TokenService(db, Options.Create(new TokenAuthOptions()), cache, clock); var active = Issue(service); Assert.NotNull(await service.ValidateTokenAsync(active.Token)); var now = DateTime.SpecifyKind(clock.Now.UtcDateTime, DateTimeKind.Unspecified); // 覆盖撤销、闲置到期、绝对到期的等值边界,以及尚差一秒到期的有效记录。 db.Insert(new[] { new UserToken { UserName = "same", TokenHash = "revoked", CreatedAt = now, ExpiresAt = now.AddDays(7), Revoked = true }, new UserToken { UserName = "same", TokenHash = "idle", CreatedAt = now.AddDays(-7), ExpiresAt = now }, new UserToken { UserName = "same", TokenHash = "absolute", CreatedAt = now.AddDays(-30), ExpiresAt = now.AddDays(1) }, new UserToken { UserName = "same", TokenHash = "near-expiry", CreatedAt = now.AddDays(-30).AddSeconds(1), ExpiresAt = now.AddSeconds(1) } }).ExecuteAffrows(); Assert.Equal(3, service.CleanupExpiredTokens()); Assert.Equal(2, db.Select().Count()); Assert.True(db.Select().Any(t => t.TokenHash == "near-expiry")); Assert.Equal(0, service.CleanupExpiredTokens()); Assert.NotNull(await service.ValidateTokenAsync(active.Token)); clock.Now = clock.Now.AddSeconds(1); Assert.Equal(1, service.CleanupExpiredTokens()); Assert.Single(db.Select().ToList()); } [Fact] public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry() { using var db = Open(":memory:"); using var cache = new MemoryCache(new MemoryCacheOptions()); var clock = new Clock(); var service = new TokenService(db, Options.Create(new TokenAuthOptions { CacheSeconds = 0 }), cache, clock); var token = Issue(service); Assert.NotNull(await service.ValidateTokenAsync(token.Token)); db.Update().Set(t => t.Revoked, true).Where(t => t.Id > 0).ExecuteAffrows(); Assert.Null(await service.ValidateTokenAsync(token.Token)); var second = Issue(service); clock.Now = clock.Now.AddDays(7); Assert.Null(await service.ValidateTokenAsync(second.Token)); Assert.Null(await service.ValidateTokenAsync("malformed")); } }