增加Shop API
This commit is contained in:
1 parent
3769193696
commit
5d017740c6
83 files changed
+4451
-696
No files matched your search
@@ -1,77 +1,164 @@
|
||||
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
|
||||
{
|
||||
[Fact]
|
||||
public async Task TokensSurviveRestart_AndLogoutOnlyRevokesCurrentSession()
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
|
||||
var options = Options.Create(new JwtOptions());
|
||||
TokenResult first;
|
||||
TokenResult second;
|
||||
string firstJti;
|
||||
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<UserToken>(e => e.Name("tokens"));
|
||||
db.CodeFirst.ConfigEntity<TokenAccount>(e => e.Name("users"));
|
||||
db.CodeFirst.SyncStructure<UserToken>();
|
||||
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<IFreeSql>(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<ITokenService>();
|
||||
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<IOnlineUserStore>();
|
||||
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()))
|
||||
{
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
first = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10");
|
||||
second = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "2001:db8::1");
|
||||
firstJti = (await tokens.ValidateTokenAsync(first.Token))!.FindFirst(JwtClaimTypes.Jti)!.Value;
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Equal(2, store.Count);
|
||||
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<UserToken>().ToList(), x => Assert.Equal(64, x.TokenHash.Length));
|
||||
}
|
||||
// 新 ORM、新存储和 TokenService,模拟进程重启后的数据库恢复。
|
||||
using (var db = Open(path))
|
||||
using (var cache = new MemoryCache(new MemoryCacheOptions()))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Contains(store.GetOnlineUsers(), x => x.LoginIp == "2001:db8::1");
|
||||
store.SignOutSession("other", firstJti);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
store.SignOutSession("same", firstJti);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
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());
|
||||
Assert.NotNull(db.Select<OnlineUserInfo>().Where(x => x.Jti == firstJti).First().RevokedAt);
|
||||
}
|
||||
using (var db = Open(path))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
store.SignOut("same");
|
||||
Assert.Null(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Null(await service.ValidateTokenAsync(second.Token));
|
||||
Assert.All(db.Select<UserToken>().ToList(), t => Assert.True(t.Revoked));
|
||||
}
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredSessionsAreRejected_EvenWithinJwtClockTolerance()
|
||||
public async Task SlidingRenewal_IsThrottled_AndCappedByAbsoluteExpiry()
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(Options.Create(new JwtOptions()), store);
|
||||
var token = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "user")]);
|
||||
db.Update<OnlineUserInfo>().Set(x => x.ExpiresAt, DateTime.UtcNow.AddSeconds(-1))
|
||||
.Where(x => x.UserName == "user").ExecuteAffrows();
|
||||
Assert.Null(await tokens.ValidateTokenAsync(token.Token));
|
||||
Assert.Empty(store.GetOnlineUsers());
|
||||
Assert.Equal(1, db.Select<OnlineUserInfo>().Count());
|
||||
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<UserToken>().First();
|
||||
clock.Now = clock.Now.AddMinutes(9);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(initial.ExpiresAt, db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = clock.Now.AddMinutes(1);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(clock.Now.UtcDateTime.AddDays(2), db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = clock.Now.AddDays(1);
|
||||
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
|
||||
Assert.Equal(initial.CreatedAt.AddDays(3), db.Select<UserToken>().First().ExpiresAt);
|
||||
clock.Now = new DateTimeOffset(initial.CreatedAt.AddDays(3), TimeSpan.Zero);
|
||||
Assert.Null(await service.ValidateTokenAsync(token.Token));
|
||||
}
|
||||
|
||||
private static IFreeSql Open(string path) => new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false")
|
||||
.Build();
|
||||
[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<UserToken>().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 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<UserToken>().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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user