137 lines
8.4 KiB
C#
137 lines
8.4 KiB
C#
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();
|
|
// Probe:probe 角色的加入令牌 → 节点自己生成密钥并注册
|
|
using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 }));
|
|
probeJoin.EnsureSuccessStatusCode();
|
|
string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
|
using var probeHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
|
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
|
probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
|
Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString());
|
|
string probeId = probeEnrolled.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.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<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");
|
|
|
|
// 按需测试: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<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)!;
|
|
}
|
|
}
|