Files
IM_NEW/Admin.Tests/AdminFlowTests.cs
T

130 lines
11 KiB
C#

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<AdminDb>(); await db.Database.MigrateAsync();
var hasher = new PasswordHasher<AdminAccount>();
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<JsonElement> Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync<JsonElement>(); return json.GetProperty("data").Clone(); }
static async Task<HttpResponseMessage> 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<AdminDb>(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); }
await new OperationWorker(factory.Services.GetRequiredService<IServiceScopeFactory>(),NullLogger<OperationWorker>.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<Guid>()})};
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<JsonElement>()).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<AdminDb>();
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<Program>
{
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<IHostedService>(); services.AddHttpClient<InternalClient>().ConfigurePrimaryHttpMessageHandler(()=>handler); });
}
}
sealed class DomainHandler : HttpMessageHandler
{
public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied;
readonly Dictionary<Guid,ActionReceipt> receipts=new();
protected override async Task<HttpResponseMessage> 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<InternalAction>(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)};
}
}