fix: align backend APIs and upload flow
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.13" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.25" />
|
||||
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup><ProjectReference Include="../Admin.WebApi/Admin.WebApi.csproj"/><ProjectReference Include="../User.Infrastructure/IdentityService.Infrastructure.csproj"/><ProjectReference Include="../GroupService.Infrastructure/GroupService.Infrastructure.csproj"/><ProjectReference Include="../FileService.Infrastructure/FileService.Infrastructure.csproj"/></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,129 @@
|
||||
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)};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using IM.Admin.Api;
|
||||
using IM.Admin.Data;
|
||||
using IM.Admin.Services;
|
||||
using IM.InitCommon.Management;
|
||||
using FileService.Infrastructure.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace IM.Admin.Tests;
|
||||
public class PolicyTests
|
||||
{
|
||||
static readonly IConfiguration Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(new byte[32]) }).Build();
|
||||
[Fact]
|
||||
public void Defaults_preserve_unlimited_legacy_rules_and_explicit_report_limits()
|
||||
{
|
||||
foreach (var id in SettingsService.Fields.Keys) SettingsService.Validate(id, SettingsService.Defaults(id));
|
||||
var p = new Policy(); Assert.True(p.RegistrationEnabled); Assert.Equal(0,p.FriendLimit); Assert.Equal(0,p.UploadMaxBytes); Assert.Equal(0,p.RecallMinutes); Assert.Equal(20,p.ReportsPerDay); Assert.Equal(10,p.ReportCooldownMinutes);
|
||||
}
|
||||
[Fact]
|
||||
public void Validation_rejects_unknown_fields_negative_limits_and_malformed_extensions()
|
||||
{
|
||||
var p = SettingsService.Defaults("social"); p["friendLimit"] = -1; Assert.Throws<ApiError>(() => SettingsService.Validate("social",p));
|
||||
p = SettingsService.Defaults("account"); p["password"] = "must not be persisted"; Assert.Throws<ApiError>(() => SettingsService.Validate("account",p));
|
||||
p = SettingsService.Defaults("messaging"); p["allowedFileTypes"] = new JsonArray("*.exe"); Assert.Throws<ApiError>(() => SettingsService.Validate("messaging",p));
|
||||
}
|
||||
[Fact]
|
||||
public void Credentials_are_authenticated_encrypted_and_require_the_original_deployment_key()
|
||||
{
|
||||
using var db = new AdminDb(new DbContextOptionsBuilder<AdminDb>().Options);
|
||||
var service = new SettingsService(db,new EphemeralDataProtectionProvider(),Config);
|
||||
var one = service.Protect("private-secret"); var two = service.Protect("private-secret");
|
||||
Assert.NotEqual(one,two); Assert.DoesNotContain("private-secret",one); Assert.Equal("private-secret",service.Unprotect(one));
|
||||
var wrong = new SettingsService(db,new EphemeralDataProtectionProvider(),new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(Enumerable.Repeat((byte)1,32).ToArray()) }).Build());
|
||||
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(() => wrong.Unprotect(one));
|
||||
}
|
||||
[Fact]
|
||||
public void Local_storage_paths_cannot_escape_the_managed_root()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(),"im-storage-test");
|
||||
Assert.StartsWith(Path.GetFullPath(root),LocalStorageAdapter.SafePath(root,"private","2026/file.txt"));
|
||||
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,"..","outside.txt"));
|
||||
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,Path.GetFullPath(Path.Combine(root,"..","outside.txt"))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user