Files
MoShiro/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

138 lines
8.4 KiB
C#

using Fleet.Core;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 对一个正在运行的 NativeAOT 发布产物跑一遍真实的 Agent 流程(注册、WebSocket、状态同步、二进制收件箱、查询、SSE、按需测试)。
/// 只有设置了 <c>MOSHIRO_AOT_URL</c> 才会运行:
/// <c>dotnet publish backend/src/MoShiro.Backend -r win-x64 -p:PublishAot=true</c>,启动产物,再设置该环境变量后运行本测试。
/// 管理员凭据与测试夹具相同(用户 admin,口令见 <see cref="BackendFixture.AdminPassword"/>)。
/// </summary>
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, Timeout = TimeSpan.FromSeconds(15) };
// 管理员:登录 → 令牌
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<string>();
using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["capabilities"] = BackendFixture.AllCapabilities() }) };
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<string>();
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();
// 拨号侧 Agent:统一加入令牌 → 节点自己生成密钥并注册
using HttpResponseMessage dialerJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["max_uses"] = 1 }));
dialerJoin.EnsureSuccessStatusCode();
string dialerToken = JsonNode.Parse(await dialerJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using var dialerHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> dialerEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
dialerHttp, baseAddress, dialerToken, "aot-dialer-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-dialer-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
Assert.True(dialerEnrolled.IsSuccess, dialerEnrolled.IsSuccess ? "" : dialerEnrolled.Error.ToString());
string dialerId = dialerEnrolled.Value.NodeId;
// 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<string>();
using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(15) };
await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]);
agent.DialerForChunks = new NodeId(dialerId);
string lineId = $"{agent.NodeId.Value}~{dialerId}";
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<string>());
Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 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<long>() == 600)
{
break;
}
await Task.Delay(100);
}
while (TimeProvider.System.GetElapsedTime(deadline) < TimeSpan.FromSeconds(15));
Assert.Equal("1m", persisted["source"]!.GetValue<string>());
Assert.Equal(600, persisted["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
// 原始块可以按 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<string>());
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");
// 按需测试:Dialer 事件 → 下发 → 状态
await agent.SendEventAsync(AgentEventKinds.DialerConnected, dialerId);
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<string>()), 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<JsonNode> 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)!;
}
}