using System.Net.Http.Headers; using System.Text.Json.Nodes; using MoShiro.Protocol; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; /// /// 对一个正在运行的 NativeAOT 发布产物跑一遍真实的 Agent 流程(注册、WebSocket、状态同步、二进制收件箱、查询、SSE、按需测试)。 /// 只有设置了 MOSHIRO_AOT_URL 才会运行: /// dotnet publish backend/src/MoShiro.Backend -r win-x64 -p:PublishAot=true,启动产物,再设置该环境变量后运行本测试。 /// 管理员凭据与测试夹具相同(用户 admin,口令见 )。 /// public sealed class AotSmokeTests { private static string? Url => Environment.GetEnvironmentVariable("MOSHIRO_AOT_URL"); [Fact] public async Task TheNativeBinary_ServesTheWholeAgentWorkflow() { Assert.SkipUnless(Url is not null, "set MOSHIRO_AOT_URL to the address of a running NativeAOT build to run this test"); var baseAddress = new Uri(Url!); using var http = new HttpClient { BaseAddress = baseAddress }; // 管理员:登录 → 令牌 using HttpResponseMessage login = await http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); login.EnsureSuccessStatusCode(); string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["scope"] = "admin" }) }; tokenRequest.Headers.Add("Cookie", cookie); tokenRequest.Headers.Add("X-CSRF-Token", csrf); using HttpResponseMessage tokenResponse = await http.SendAsync(tokenRequest); tokenResponse.EnsureSuccessStatusCode(); string token = JsonNode.Parse(await tokenResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); settingsResponse.EnsureSuccessStatusCode(); using HttpResponseMessage probeResponse = await http.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "aot-probe-" + Guid.NewGuid().ToString("N")[..6] })); probeResponse.EnsureSuccessStatusCode(); string probeId = JsonNode.Parse(await probeResponse.Content.ReadAsStringAsync())!["probe_id"]!.GetValue(); // Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态 using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 })); joinResponse.EnsureSuccessStatusCode(); string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); using var anonymous = new HttpClient { BaseAddress = baseAddress }; await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]); agent.ProbeForChunks = new ProbeId(probeId); string lineId = $"{agent.NodeId.Value}~{probeId}"; await agent.ConnectAsync(baseAddress); TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2); Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo"); // 原始块:二进制通道 → 段文件与索引 → ACK long minute = 60_000; long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute; for (int k = 0; k < 6; k++) { Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000)))); } // 查询:热层的精确汇总,随后是持久层的分钟窗口 string range = $"from={Iso(baseMs)}&to={Iso(baseMs + minute)}&metrics=loss.total,rtt.p95"; JsonNode hot = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1s"); Assert.Equal("hot", hot["source"]!.GetValue()); Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue()); Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); long deadline = TimeProvider.System.GetTimestamp(); JsonNode persisted; do { persisted = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1m"); if (persisted["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600) { break; } await Task.Delay(100); } while (TimeProvider.System.GetElapsedTime(deadline) < TimeSpan.FromSeconds(15)); Assert.Equal("1m", persisted["source"]!.GetValue()); Assert.Equal(600, persisted["summary"]!["samples"]!["udp_echo"]!.GetValue()); // 原始块可以按 ID 下载并解码 JsonNode chunks = await GetAsync(http, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=1"); byte[] bytes = await http.GetByteArrayAsync("/api/admin/raw/chunks/" + chunks["items"]![0]!["chunk_id"]!.GetValue()); Assert.True(RawChunkCodec.Decode(bytes).IsSuccess); // SSE:快照 + 增量 await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{lineId}:1s:udp"); await sse.WaitForAsync(e => e.Name == "series"); // 按需测试:Probe 事件 → 下发 → 状态 await agent.SendEventAsync(AgentEventKinds.ProbeConnected, probeId); JsonNode started = null!; long begun = TimeProvider.System.GetTimestamp(); while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10)) { using HttpResponseMessage response = await http.PostAsync($"/api/admin/lines/{lineId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); if (response.StatusCode == System.Net.HttpStatusCode.Accepted) { started = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; break; } await Task.Delay(100); } Assert.NotNull(started); await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); await agent.SendTestStatusAsync(new TestId(started["id"]!.GetValue()), TestStatuses.Running, 0); await sse.WaitForAsync(e => e.Name == "node"); // 安全:匿名不能访问管理接口 using HttpResponseMessage denied = await anonymous.GetAsync("/api/admin/agents"); Assert.Equal(System.Net.HttpStatusCode.Unauthorized, denied.StatusCode); } private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); private static async Task GetAsync(HttpClient http, string url) { using HttpResponseMessage response = await http.GetAsync(url); string text = await response.Content.ReadAsStringAsync(); Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}"); return JsonNode.Parse(text)!; } }