using System.Net; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json; using IM.Admin.Data; using IM.Admin.Services; using IM.InitCommon.Management; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Testcontainers.MySql; using Xunit; namespace IM.Admin.Tests; public sealed class AdminFlowTests : IAsyncLifetime { readonly MySqlContainer mysql = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("admin_tests").WithUsername("test_admin").WithPassword(Convert.ToHexString(RandomNumberGenerator.GetBytes(24))).Build(); Factory factory = null!; readonly DomainHandler domain = new(); const string Password = "Integration-password-456!"; readonly Guid superId = Guid.NewGuid(), reviewerId = Guid.NewGuid(); public async Task InitializeAsync() { await mysql.StartAsync(); factory = new Factory(mysql.GetConnectionString(),domain); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); var hasher = new PasswordHasher(); foreach (var (id,name,role) in new[] {(superId,"root_test","super"),(reviewerId,"review_test","reviewer")}) { var a = new AdminAccount {Id=id,Account=name,Name=name,Role=role}; a.PasswordHash=hasher.HashPassword(a,Password); db.Accounts.Add(a); } await db.SaveChangesAsync(); } public async Task DisposeAsync() { await factory.DisposeAsync(); await mysql.DisposeAsync(); } HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions {BaseAddress=new Uri("https://localhost"),AllowAutoRedirect=false,HandleCookies=true}); static async Task Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync(); return json.GetProperty("data").Clone(); } static async Task Mutate(HttpClient client,string path,object body,HttpMethod? method=null) { var csrf=await Data(await client.GetAsync("/api/admin/auth/csrf")); using var request=new HttpRequestMessage(method??HttpMethod.Post,"/api/admin"+path) {Content=JsonContent.Create(body)}; request.Headers.Add("X-CSRF-TOKEN",csrf.GetProperty("token").GetString()); return await client.SendAsync(request); } static async Task Login(HttpClient c,string name) => Assert.Equal(HttpStatusCode.OK,(await Mutate(c,"/auth/login",new {account=name,password=Password})).StatusCode); async Task Process(Guid id) { using (var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); } await new OperationWorker(factory.Services.GetRequiredService(),NullLogger.Instance).Process(CancellationToken.None); } [Fact] public async Task Cookie_csrf_permissions_versions_durable_failures_and_revocation_work_together() { using var root=Client(); using var reviewer=Client(); Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/settings")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest,(await root.PostAsJsonAsync("/api/admin/auth/login",new {account="root_test",password=Password})).StatusCode); await Login(root,"root_test"); await Login(reviewer,"review_test"); foreach(var route in new[]{"settings","admins","logs","health","storage"}) Assert.Equal(HttpStatusCode.Forbidden,(await reviewer.GetAsync("/api/admin/"+route)).StatusCode); var me=await Data(await root.GetAsync("/api/admin/auth/me")); Assert.False(me.TryGetProperty("passwordHash",out _)); var settings=await Data(await root.GetAsync("/api/admin/settings")); var account=settings.EnumerateArray().Single(x=>x.GetProperty("id").GetString()=="account"); var input=new {version=account.GetProperty("version").GetInt64(),value=new {registrationEnabled=false,passwordMinLength=10},reason="测试暂停注册"}; Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode); Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode); var platform=await Data(await root.GetAsync("/api/platform")); Assert.False(platform.GetProperty("registrationEnabled").GetBoolean()); using var internalRequest=new HttpRequestMessage(HttpMethod.Post,"/internal/management/reports") {Content=JsonContent.Create(new {reporterId=Guid.NewGuid(),type="user",targetId=domain.Target,reason="骚扰辱骂",description="服务端快照测试",messageIds=Array.Empty()})}; internalRequest.Headers.Add("X-IM-Management-Key","integration-internal-key"); var submitted=await root.SendAsync(internalRequest); Assert.Equal(HttpStatusCode.OK,submitted.StatusCode); var reportId=(await submitted.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid(); Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/reports/{reportId}/claim",new{})).StatusCode); Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/claim",new{})).StatusCode); var operationId=Guid.NewGuid(); var review=new {operationId,action="封禁",reason="核实违规后处置"}; Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/review",review)).StatusCode); domain.Fail=true; Assert.Equal(HttpStatusCode.Accepted,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode); for(var attempt=0;attempt<3;attempt++) await Process(operationId); var pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString()); var failed=await Data(await reviewer.GetAsync($"/api/admin/operations/{operationId}")); Assert.Equal("failed",failed.GetProperty("status").GetString()); Assert.Equal(0,domain.Applied); domain.Fail=false; domain.LoseAcknowledgement=true; Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/operations/{operationId}/retry",new{})).StatusCode); await Process(operationId); Assert.Equal(1,domain.Applied); pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString()); await Process(operationId); Assert.Equal(1,domain.Applied); var closed=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("已处理",closed.GetProperty("status").GetString()); Assert.Equal(HttpStatusCode.Conflict,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode); using(var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService(); Assert.Equal(1,await db.Audit.CountAsync(x=>x.OperationId==operationId)); Assert.True(await db.Audit.AnyAsync(x=>x.Action=="查看举报证据"&&x.ReportId==reportId)); Assert.True(await db.Audit.AnyAsync(x=>x.Result.Contains("执行失败"))); } var adminEdit=new {account="root_test",name="root_test",email="",password="",role="reviewer",enabled=false,reason="不允许停用最后超级管理员"}; Assert.Equal(HttpStatusCode.BadRequest,(await Mutate(root,$"/admins/{superId}",adminEdit,HttpMethod.Put)).StatusCode); Assert.Equal(HttpStatusCode.OK,(await Mutate(root,$"/admins/{reviewerId}",new {account="review_test",name="review_test",email="",password="",role="reviewer",enabled=false,reason="停用测试账号"},HttpMethod.Put)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized,(await reviewer.GetAsync("/api/admin/reports")).StatusCode); Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/auth/password",new {currentPassword=Password,newPassword="New-integration-password!"})).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/auth/me")).StatusCode); } sealed class Factory(string connection,DomainHandler handler) : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); builder.UseSetting("ConnectionStrings:Admin",connection); builder.UseSetting("Management:KeyRingPath",Path.Combine(Path.GetTempPath(),"im-admin-test-keys",Guid.NewGuid().ToString("N"))); builder.UseSetting("Management:CredentialKey",Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))); builder.UseSetting("Management:InternalKey","integration-internal-key"); foreach(var name in new[]{"user","group","message","contact","file","connector","admin"}) builder.UseSetting($"Management:Services:{name}",$"http://{name}.test"); builder.ConfigureServices(services=> { services.RemoveAll(); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(()=>handler); }); } } sealed class DomainHandler : HttpMessageHandler { public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied; readonly Dictionary receipts=new(); protected override async Task SendAsync(HttpRequestMessage request,CancellationToken ct) { if(request.RequestUri!.AbsolutePath.EndsWith("/evidence")) return Ok(new SubjectEvidence("测试对象",[])); if(request.RequestUri.AbsolutePath.EndsWith("/action")) { if(Fail) return new(HttpStatusCode.ServiceUnavailable); var command=await request.Content!.ReadFromJsonAsync(cancellationToken:ct); if(!receipts.TryGetValue(command!.Id,out var receipt)) { receipt=new("测试对象","正常","封禁"); receipts.Add(command.Id,receipt); Applied++; } if(LoseAcknowledgement) { LoseAcknowledgement=false; throw new HttpRequestException("Injected response loss after commit"); } return Ok(receipt); } return Ok(new {total=1}); } static HttpResponseMessage Ok(object value)=>new(HttpStatusCode.OK) {Content=JsonContent.Create(value)}; } }