using System.Net.Http.Headers; using System.Text; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; /// 首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。 internal static class Defaults { public const int TaskCount = 3; } /// /// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询), /// 并提供管理员客户端(Bearer 令牌)和假 Agent。 /// public class BackendFixture : IAsyncLifetime { public const string AdminUser = "admin"; public const string AdminPassword = "correct horse battery"; private readonly List _agents = []; private string _adminToken = ""; private int _counter; /// 所有测试都从同一个回环地址登录,所以共享的后端放宽限流;专门测试限流的类用 。 protected virtual int LoginAttemptsPerMinute => 1000; public WebApplication App { get; private set; } = null!; public Uri BaseAddress { get; private set; } = null!; public string DataDirectory { get; private set; } = ""; public HttpClient Anonymous { get; private set; } = null!; /// 带 admin 作用域的 API 令牌的客户端。 public HttpClient Admin { get; private set; } = null!; public IServiceProvider Services => App.Services; /// 夹具创建的默认 Probe:合成块()和单线路的查询都用它。 public string DefaultProbeId { get; private set; } = ""; /// 默认 Probe 与这个 Agent 组成的线路 id(agent~probe)。 public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}"; public string Line(string agentId) => $"{agentId}~{DefaultProbeId}"; /// 在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。 public async Task CreateProbeAsync(string? name = null) { using HttpResponseMessage response = await Admin.PostAsync("/api/admin/probes", Json(new JsonObject { ["name"] = name ?? UniqueName("probe") })); string body = await response.Content.ReadAsStringAsync(); Assert.True(response.IsSuccessStatusCode, body); return JsonNode.Parse(body)!; } public async ValueTask InitializeAsync() { DataDirectory = Path.Combine(Path.GetTempPath(), "moshiro-tests-" + Guid.NewGuid().ToString("N")); await StartAsync(); _adminToken = await CreateAdminTokenAsync(); CreateClients(); DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue(); } public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); foreach (AgentSimulator agent in _agents) { await agent.DisposeAsync(); } Anonymous.Dispose(); Admin.Dispose(); await StopAsync(); try { Directory.Delete(DataDirectory, recursive: true); } catch (IOException) { // 临时目录清理失败不影响测试结果 } } /// 重启后端(保留数据目录):用来验证数据是否真的落盘。 public async Task RestartAsync() { Anonymous.Dispose(); Admin.Dispose(); await StopAsync(); await StartAsync(); CreateClients(); // 新的端口:HttpClient 的 BaseAddress 只能在第一次请求之前设置 } /// 用同一个身份重新连接一个已经断开的假 Agent。 public async Task ReconnectAsync(AgentSimulator previous) { AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId); agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId); _agents.Add(agent); await agent.ConnectAsync(BaseAddress); return agent; } private void CreateClients() { Anonymous = NewClient(); Admin = NewClient(); Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken); } public HttpClient NewClient() => new() { BaseAddress = BaseAddress }; public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}"; public async Task CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1) { using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours })); response.EnsureSuccessStatusCode(); return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } /// 注册并连接一个假 Agent。 public async Task ConnectAgentAsync(string? name = null, bool autoApprove = true, string[]? kinds = null, bool hello = true) { string token = await CreateJoinTokenAsync(autoApprove); AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"), kinds); agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId); _agents.Add(agent); await agent.ConnectAsync(BaseAddress, hello, kinds); return agent; } public async Task EnrollOnlyAsync(string? name = null, bool autoApprove = true) { string token = await CreateJoinTokenAsync(autoApprove); AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent")); _agents.Add(agent); return agent; } public async Task GetJsonAsync(HttpClient client, string url) { using HttpResponseMessage response = await client.GetAsync(url); string text = await response.Content.ReadAsStringAsync(); Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}"); return JsonNode.Parse(text)!; } public static StringContent Json(JsonNode node, string mediaType = "application/json") => new(node.ToJsonString(), Encoding.UTF8, mediaType); public async Task SiteMustBePublicAsync() { using HttpResponseMessage response = await Admin.PatchAsync("/api/admin/settings", Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); response.EnsureSuccessStatusCode(); } /// 轮询直到条件成立(后台分析、状态推送都是异步的)。 public static async Task EventuallyAsync(Func> condition, TimeSpan? timeout = null, string? because = null) { long started = TimeProvider.System.GetTimestamp(); TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15); while (TimeProvider.System.GetElapsedTime(started) < limit) { if (await condition()) { return; } await Task.Delay(50); } Assert.Fail("condition was not met in time" + (because is null ? "" : ": " + because)); } private async Task StartAsync() { App = BackendHost.Build( ["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"], options => { options.DataDirectory = DataDirectory; options.Pbkdf2Iterations = 1000; options.SecureCookies = false; options.WindowGrace = TimeSpan.Zero; options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100); options.InitialAdminUser = AdminUser; options.InitialAdminPassword = AdminPassword; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务 options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }); await App.StartAsync(); string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); BaseAddress = new Uri(address); } private async Task StopAsync() { await App.StopAsync(); await App.DisposeAsync(); } private async Task CreateAdminTokenAsync() { using HttpClient session = NewClient(); using HttpResponseMessage login = await session.PostAsync( "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword })); login.EnsureSuccessStatusCode(); JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) }; request.Headers.Add("Cookie", cookie); request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue()); using HttpResponseMessage created = await session.SendAsync(request); created.EnsureSuccessStatusCode(); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } } /// 登录限流为每分钟 10 次(默认值)的独立后端。 public sealed class StrictLoginFixture : BackendFixture { protected override int LoginAttemptsPerMinute => 10; }