chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,308 @@
using System.Text.Json.Nodes;
using Monitor.Measurement;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。</summary>
[Collection("backend")]
public sealed class AgentEndToEndTests
{
private readonly BackendFixture _backend;
public AgentEndToEndTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync()
{
AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
ProbeSimulator probe = await agent.ConnectProbeAsync();
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
}
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
[Fact]
public async Task Agent_ConnectsAndReportsItsRealPorts_AndTheProbeSessionIsVisibleToTheBackend()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
JsonNode view = await AgentViewAsync(agent);
Assert.Equal("active", view["status"]!.GetValue<string>());
Assert.False(view["probe_connected"]!.GetValue<bool>());
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
}
[Fact]
public async Task LocalLimits_RejectTasksOutsideTheAllowedPorts_AndTheReasonReachesTheAdmin()
{
// 默认模板的端口(7443/7444)不在这个 Agent 实际监听的端口里:任务被拒绝,原因是 policy_denied
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 2);
JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!;
Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue<string>()));
// 覆盖成实际端口之后恢复
await agent.OverrideLineTasksAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 0);
}
[Fact]
public async Task UdpEcho_EndToEnd_FragmentsAreMergedWithAgentTimestamps_AndStoredByTheBackend()
{
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
await using (agent)
await using (probe)
{
(int sent, int received) = await probe.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40));
Assert.Equal(40, sent);
Assert.Equal(40, received);
JsonNode stored = null!;
await BackendFixture.EventuallyAsync(async () =>
{
stored = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}");
return stored["items"]!.AsArray().Count >= 1;
}, because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} outbox={agent.App.Link.Outbox.PendingCount}");
JsonNode chunk = stored["items"]![0]!;
Assert.True(chunk["kind"] is not null, stored.ToJsonString());
Assert.Equal("udp_echo", chunk["kind"]!.GetValue<string>());
Assert.True(agent.App.Counters.Fragments >= 1);
Assert.True(agent.App.Counters.PacketsIn >= 40);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task TcpEcho_EndToEnd_AuthenticatedFirstFrame_PlainOrTls(bool tls)
{
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
await using (agent)
await using (probe)
{
LineTask tcp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo");
(int sent, int received) = await probe.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls);
Assert.Equal(15, sent);
Assert.Equal(15, received);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "tcp_echo"));
}
}
[Fact]
public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame()
{
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
await using (agent)
await using (probe)
{
using var client = new System.Net.Sockets.TcpClient();
await client.ConnectAsync(System.Net.IPAddress.Loopback, agent.App.TcpPorts[0]);
await client.GetStream().WriteAsync(new byte[TcpWire.AuthBytes]); // 全零:magic 不对
byte[] buffer = new byte[16];
Assert.Equal(0, await client.GetStream().ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid >= 1));
}
}
[Fact]
public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering()
{
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
await using (agent)
await using (probe)
{
long before = agent.App.Counters.DroppedInvalid;
await probe.SendRawUdpAsync([1, 2, 3]);
await probe.SendRawUdpAsync(new byte[64]);
await probe.SendForgedEchoAsync(udp, 1);
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted");
// 重放:同一个序号发两次,只有第一次被接受
await probe.SendEchoAsync(udp, 500);
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1));
long dropped = agent.App.Counters.DroppedInvalid;
await probe.SendEchoAsync(udp, 500);
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped");
}
}
[Fact]
public async Task ARestartedProbe_NeverInheritsTheAgentRecordsOfItsPreviousSession()
{
(AgentHarness agent, ProbeSimulator first, LineTask udp) = await LineAsync();
await using (agent)
{
await first.RunUdpEchoAsync(udp, 20, TimeSpan.FromMilliseconds(40));
RowStore records = agent.App.Streams.Records(udp.StreamId, "msg")!;
Assert.True(records.Contains(5), "the agent recorded t2/t3 for the first session's packets");
await first.DisposeAsync();
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
await using ProbeSimulator second = first.Restarted();
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId);
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
Assert.NotEqual(udp.StreamId, udp2.StreamId);
// The previous session's streams are released with it ...
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Streams.Records(udp.StreamId, "msg") is null), because: "the previous session's streams must be released");
// ... and a packet the new session never sent has no agent record: if the old t2/t3 survived, a lost uplink packet would look like a downlink loss
RowStore fresh = agent.App.Streams.Records(udp2.StreamId, "msg")!;
Assert.False(fresh.Contains(3));
Assert.False(fresh.Contains(5));
}
}
[Fact]
public async Task SeveralProbes_AreReportedTogether_CanBeDroppedOneByOne_AndARevokedOneIsKickedOut()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
await using ProbeSimulator a = await agent.ConnectProbeAsync();
await using ProbeSimulator b = await agent.ConnectProbeAsync();
// The agent reports both sessions with version and address; the probe view shows the agent it is connected to
JsonNode view = null!;
await BackendFixture.EventuallyAsync(
async () =>
{
view = await AgentViewAsync(agent);
return view["probes"]!.AsArray().Count == 2;
},
because: "the agent reports every connected probe");
Assert.All(view["probes"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue<string>()));
Assert.Equal("127.0.0.1", view["probes"]![0]!["remote"]!.GetValue<string>());
JsonNode probeView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + a.ProbeId);
Assert.Equal(agent.Id, probeView["connections"]![0]!["agent_id"]!.GetValue<string>());
// Both get their own task set (own streams)
LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo"));
LineTaskSet setB = await b.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo"));
Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "udp_echo").StreamId, setB.Tasks.Single(t => t.Kind == "udp_echo").StreamId);
// Drop one: the other stays
using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null);
Assert.Equal(System.Net.HttpStatusCode.NoContent, dropped.StatusCode);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 1, because: "only the dropped probe leaves");
Assert.True(b.IsConnected);
using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null);
Assert.Equal(System.Net.HttpStatusCode.Conflict, again.StatusCode);
// Revoke the remaining one: the agent closes its session
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{b.ProbeId}/revoke", null);
revoked.EnsureSuccessStatusCode();
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 0, because: "a revoked probe is disconnected");
}
[Fact]
public async Task PeerHandshake_RejectsAProbeWhoseKeyTheBackendNeverIssued()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.CreateProbeAsync();
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId));
// 证书指纹不对(中间人)也不会连上
await using ProbeSimulator probe = await agent.CreateProbeAsync();
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, new byte[32], agent.App.UdpPorts[0], agent.NodeId));
}
[Fact]
public async Task ProbeDisconnect_IsReportedToTheBackend()
{
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
await using (agent)
{
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
await probe.DisposeAsync();
await BackendFixture.EventuallyAsync(async () => !(await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
}
}
[Fact]
public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe()
{
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
await using (agent)
await using (probe)
{
JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks");
JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue<string>() == agent.Id && t["kind"]!.GetValue<string>() == "udp_echo")!;
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue<string>(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
disabled.EnsureSuccessStatusCode();
LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Tasks.Count >= 0 && s.Version > 0);
Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id);
}
}
[Fact]
public async Task Hy2Ladder_EndToEnd_AgentSendsTheTrain_ProbeReportsReceipts_AndTheTestSucceeds()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync();
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
JsonNode test = null!;
await BackendFixture.EventuallyAsync(
async () =>
{
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
},
TimeSpan.FromSeconds(90));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
Assert.Equal(4, probe.Phases.Count);
Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind));
// 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块
Assert.True(agent.App.Counters.Fragments > 4);
await BackendFixture.EventuallyAsync(
async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == "udp_train") == agent.App.Counters.Fragments,
because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} pending={agent.App.Link.Outbox.PendingCount}/{agent.App.Link.Outbox.PendingBytes} dropped={agent.App.Link.Outbox.Dropped} link={agent.App.Link.IsConnected}");
}
[Fact]
public async Task Plan_UsingTcpBulk_IsAcceptedByARealAgentAndStartsItsFirstPhase()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
// reality_session 需要 connect、tcp_bulk、hold:真实的 Agent 都实现了
using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode);
await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.Phases.IsEmpty), because: "the first phase should start");
Assert.True(probe.Phases.TryPeek(out var first));
Assert.Equal("connect", first.Kind);
}
[Fact]
public async Task Test_IsRefusedWhenNoProbeIsConnected_AndWhenAPhaseExceedsTheLocalLimits()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
await agent.OverrideLineTasksAsync();
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.UdpPorts[0]) } };
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
run.ProbeId = new ProbeId(probe.ProbeId);
phase.Params["mbps"] = System.Text.Json.JsonSerializer.SerializeToElement(50);
Assert.Equal("budget_exceeded", agent.App.Tests.Start(run).Error.Code.Value);
}
}
@@ -0,0 +1,141 @@
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Enrollment.Client;
using Fleet.Hosting;
using Fleet.Identity;
using Monitor.Agent;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>
/// 进程内的真实 Agent:用 <see cref="EnrollmentClient"/> 注册(和 <c>fleet-enroll</c> 同一份代码)、写出身份目录、
/// 在随机端口上启动 <see cref="AgentApp"/> 并连接真实的后端。
/// </summary>
public sealed class AgentHarness : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
private readonly string _directory;
private readonly LoadedNodeIdentity _identity;
private readonly Task _run;
private AgentHarness(BackendFixture backend, string directory, LoadedNodeIdentity identity, AgentApp app)
{
Backend = backend;
_directory = directory;
_identity = identity;
App = app;
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
}
public BackendFixture Backend { get; }
public AgentApp App { get; }
public NodeId NodeId => _identity.NodeId;
public string Id => _identity.NodeId.Value;
public string DataDirectory => _directory;
public byte[] TlsSpki => Convert.FromBase64String(_identity.File.TlsSpki);
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null)
{
string directory = Path.Combine(Path.GetTempPath(), "monitor-agent-" + Guid.NewGuid().ToString("N"));
string token = await backend.CreateJoinTokenAsync();
using var http = new HttpClient();
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None);
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
var config = new AgentConfig
{
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
Listen = { Bind = "127.0.0.1", ControlPort = 0, UdpPorts = [0], TcpPorts = [0] },
};
configure?.Invoke(config);
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
var app = new AgentApp(config, identity, TimeProvider.System, new ConsoleNodeLog(TimeProvider.System, Console.Out));
var harness = new AgentHarness(backend, directory, identity, app);
await app.Ready.WaitAsync(TimeSpan.FromSeconds(10));
await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend");
return harness;
}
/// <summary>在后端生成 Probe 身份(私钥只返回一次),并构造连到这个 Agent 的假 Probe。</summary>
public async Task<ProbeSimulator> CreateProbeAsync()
{
JsonNode identity = await Backend.CreateProbeAsync();
NodeIdentity key = NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>()));
string publicKey = Convert.ToBase64String(key.PublicKeySpki);
// 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝)
await BackendFixture.EventuallyAsync(
() => Task.FromResult(App.IsPeerKnown(identity["probe_id"]!.GetValue<string>(), publicKey)),
because: "the agent should learn the probe's key");
return new ProbeSimulator(identity["probe_id"]!.GetValue<string>(), key);
}
public async Task<ProbeSimulator> ConnectProbeAsync()
{
ProbeSimulator probe = await CreateProbeAsync();
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsSpki, App.UdpPorts[0], NodeId);
return probe;
}
/// <summary>为这个 Agent 创建覆盖任务,让线路任务使用它实际监听的端口(默认模板里是 7443/7444,不在本地允许的端口里)。</summary>
public async Task OverrideLineTasksAsync(int? rateHz = null)
{
JsonNode tasks = await Backend.GetJsonAsync(Backend.Admin, "/api/admin/tasks");
foreach (JsonNode? task in tasks["items"]!.AsArray())
{
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo"))
{
continue;
}
string kind = task["kind"]!.GetValue<string>();
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
if (rateHz is { } hz)
{
parameters["rate_hz"] = hz;
}
using HttpResponseMessage created = await Backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line",
["kind"] = kind,
["agent_id"] = Id,
["base_task_id"] = task["id"]!.GetValue<string>(),
["params"] = parameters,
}));
Assert.True(created.StatusCode == System.Net.HttpStatusCode.Created, await created.Content.ReadAsStringAsync());
}
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 停止中
}
App.Dispose();
_identity.Dispose();
_stop.Dispose();
try
{
Directory.Delete(_directory, recursive: true);
}
catch (IOException)
{
// 临时目录清理失败不影响测试结果
}
}
}
@@ -0,0 +1,121 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>
/// 对一个正在运行的 NativeAOT 发布产物跑一遍真实的 Agent 流程(注册、WebSocket、状态同步、二进制收件箱、查询、SSE、按需测试)。
/// 只有设置了 <c>MONITOR_AOT_URL</c> 才会运行:
/// <c>dotnet publish backend/src/Monitor.Backend -r win-x64 -p:PublishAot=true</c>,启动产物,再设置该环境变量后运行本测试。
/// 管理员凭据与测试夹具相同(用户 admin,口令见 <see cref="BackendFixture.AdminPassword"/>)。
/// </summary>
public sealed class AotSmokeTests
{
private static string? Url => Environment.GetEnvironmentVariable("MONITOR_AOT_URL");
[Fact]
public async Task TheNativeBinary_ServesTheWholeAgentWorkflow()
{
Assert.SkipUnless(Url is not null, "set MONITOR_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<string>();
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<string>();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using var anonymous = new HttpClient { BaseAddress = baseAddress };
await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]);
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/{agent.NodeId.Value}/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/{agent.NodeId.Value}/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:{agent.NodeId.Value}:1s:udp");
await sse.WaitForAsync(e => e.Name == "series");
// 按需测试:Probe 事件 → 下发 → 状态
await agent.SendEventAsync(AgentEventKinds.ProbeConnected);
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/{agent.NodeId.Value}/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)!;
}
}
@@ -0,0 +1,201 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class ApiBasicsTests
{
private readonly BackendFixture _backend;
public ApiBasicsTests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public async Task Healthz_ReportsStorageHealth_AndOpenApiIsServed()
{
JsonNode health = await _backend.GetJsonAsync(_backend.Anonymous, "/healthz");
Assert.Equal("ok", health["status"]!.GetValue<string>());
Assert.True(health["database"]!.GetValue<bool>());
Assert.True(health["raw_directory"]!.GetValue<bool>());
JsonNode openapi = await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json");
Assert.NotNull(openapi["paths"]!["/api/public/overview"]);
Assert.Equal("overview_get", openapi["paths"]!["/api/public/overview"]!["get"]!["operationId"]!.GetValue<string>());
}
[Fact]
public async Task Admin_WithoutCredentials_Returns401ProblemJson_WithCodeAndTraceId()
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Equal("unauthorized", problem["code"]!.GetValue<string>());
Assert.Equal(401, problem["status"]!.GetValue<int>());
Assert.False(string.IsNullOrEmpty(problem["trace_id"]!.GetValue<string>()));
}
[Fact]
public async Task Login_WrongPassword_IsGeneric401_AndSuccessSetsAHardenedCookie()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage wrong = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "admin", ["password"] = "nope" }));
using HttpResponseMessage unknown = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "nobody", ["password"] = "nope" }));
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
Assert.Equal(
JsonNode.Parse(await wrong.Content.ReadAsStringAsync())!["detail"]!.GetValue<string>(),
JsonNode.Parse(await unknown.Content.ReadAsStringAsync())!["detail"]!.GetValue<string>());
using HttpResponseMessage ok = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
string cookie = ok.Headers.GetValues("Set-Cookie").Single();
Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase);
Assert.Contains("samesite=strict", cookie, StringComparison.OrdinalIgnoreCase);
Assert.Equal("no-store", ok.Headers.CacheControl!.ToString());
}
[Fact]
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
using var get = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
get.Headers.Add("Cookie", cookie);
using HttpResponseMessage me = await client.SendAsync(get);
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
withoutCsrf.Headers.Add("Cookie", cookie);
using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
wrongCsrf.Headers.Add("Cookie", cookie);
wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x");
using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
good.Headers.Add("Cookie", cookie);
good.Headers.Add("X-CSRF-Token", csrf);
using HttpResponseMessage created = await client.SendAsync(good);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
[Fact]
public async Task ReadScopedToken_CanReadButNotManage()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "read" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
using HttpClient reader = _backend.NewClient();
reader.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using HttpResponseMessage manage = await reader.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Forbidden, manage.StatusCode);
using HttpResponseMessage read = await reader.GetAsync("/api/public/catalog/kinds");
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
using HttpClient bogus = _backend.NewClient();
bogus.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "mon_bogus");
using HttpResponseMessage unknown = await bogus.GetAsync("/api/admin/agents");
Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode);
}
[Fact]
public async Task PrivateSite_HidesThePublicApiFromAnonymousVisitors_UntilMadePublic()
{
using HttpResponseMessage makePrivate = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, makePrivate.StatusCode);
using HttpResponseMessage hidden = await _backend.Anonymous.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.Forbidden, hidden.StatusCode);
Assert.Equal("site_private", JsonNode.Parse(await hidden.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage authed = await _backend.Admin.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.OK, authed.StatusCode);
await _backend.SiteMustBePublicAsync();
using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/overview");
Assert.Equal(HttpStatusCode.OK, visible.StatusCode);
}
[Fact]
public async Task Settings_RejectUnknownKeysAndOutOfRangeValues()
{
using HttpResponseMessage response = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 0, ["nope"] = 1 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Equal("validation_failed", problem["code"]!.GetValue<string>());
Assert.NotNull(problem["errors"]!["retention.raw_days"]);
Assert.NotNull(problem["errors"]!["nope"]);
}
[Fact]
public async Task Catalog_IsGeneratedFromTheRegistries_WithCachingHeaders()
{
await _backend.SiteMustBePublicAsync();
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/catalog/metrics");
Assert.Equal("public, max-age=300", response.Headers.CacheControl!.ToString());
string etag = response.Headers.ETag!.Tag;
JsonNode metrics = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
Assert.Contains(metrics["items"]!.AsArray(), m => m!["id"]!.GetValue<string>() == "rtt.p95" && m["unit"]!.GetValue<string>() == "ms");
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/public/catalog/metrics");
conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
using HttpResponseMessage notModified = await _backend.Anonymous.SendAsync(conditional);
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
JsonNode kinds = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/catalog/kinds");
JsonNode udp = kinds["items"]!.AsArray().Single(k => k!["id"]!.GetValue<string>() == "udp_echo")!;
JsonNode rate = udp["params"]!.AsArray().Single(p => p!["name"]!.GetValue<string>() == "rate_hz")!;
Assert.Equal(1, rate["min"]!.GetValue<int>());
Assert.Equal(200, rate["max"]!.GetValue<int>());
Assert.Equal(10, rate["default"]!.GetValue<int>());
}
[Fact]
public async Task UnknownRoutesAndBadBodies_AreProblemJsonWithCodes()
{
using HttpResponseMessage notFound = await _backend.Admin.GetAsync("/api/admin/nope");
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
Assert.Equal("not_found", JsonNode.Parse(await notFound.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage badBody = await _backend.Admin.PostAsync("/api/admin/tasks", new StringContent("{not json", System.Text.Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, badBody.StatusCode);
Assert.Equal("validation_failed", JsonNode.Parse(await badBody.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
[Fact]
public async Task Metrics_RequireAnAdminToken_AndExposePrometheusText()
{
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
using HttpResponseMessage allowed = await _backend.Admin.GetAsync("/metrics");
string text = await allowed.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Contains("monitor_agents_online", text);
Assert.Contains("monitor_analysis_queue_length", text);
}
[Fact]
public async Task InstallScripts_AreNotShippedYet()
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
Assert.Equal("not_implemented", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
}
[CollectionDefinition("backend")]
public sealed class BackendCollection : ICollectionFixture<BackendFixture>;
@@ -0,0 +1,226 @@
using System.Xml.Linq;
namespace Monitor.Backend.Tests;
/// <summary>
/// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制,等价于文档里的 <c>eng/check-deps</c>。
/// 读取仓库里所有 .csproj 和源文件,违反规则就失败。
/// </summary>
public sealed class ArchitectureTests
{
private static readonly string s_root = FindRoot();
private static readonly string[] s_allowedAnalyzers = ["Microsoft.CodeAnalysis.BannedApiAnalyzers"];
private sealed record Project(string Name, string Path, string Directory, IReadOnlyList<string> ProjectReferences, IReadOnlyList<string> Packages);
private static string FindRoot()
{
string? dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "global.json")))
{
dir = Path.GetDirectoryName(dir);
}
return dir ?? throw new InvalidOperationException("repository root not found");
}
private static List<Project> LoadProjects() =>
[.. Directory.EnumerateFiles(s_root, "*.csproj", SearchOption.AllDirectories)
.Where(p => !p.Contains(Path.DirectorySeparatorChar + "artifacts" + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !p.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))
.Select(path =>
{
XDocument doc = XDocument.Load(path);
IEnumerable<XElement> items = doc.Descendants("ItemGroup").Elements();
return new Project(
Path.GetFileNameWithoutExtension(path),
path,
Path.GetDirectoryName(path)!,
[.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension((string?)e.Attribute("Include") ?? ""))],
[.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]);
})];
private static bool IsTest(Project p) => p.Path.Contains(Path.DirectorySeparatorChar + "tests" + Path.DirectorySeparatorChar, StringComparison.Ordinal);
private static bool IsFleet(Project p) => p.Name.StartsWith("Fleet.", StringComparison.Ordinal) && !IsTest(p);
[Fact]
public void Fleet_NeverReferencesMonitor_InProjectsOrCode()
{
foreach (Project project in LoadProjects().Where(IsFleet))
{
Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("Monitor.", StringComparison.Ordinal));
foreach (string file in Directory.EnumerateFiles(project.Directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
{
string text = File.ReadAllText(file);
Assert.False(text.Contains("using Monitor.", StringComparison.Ordinal) || text.Contains("namespace Monitor.", StringComparison.Ordinal), $"{file} refers to Monitor.*");
Assert.False(text.Contains("monitor.", StringComparison.Ordinal) && text.Contains("\"monitor.", StringComparison.Ordinal), $"{file} hard-codes a Monitor method or channel name");
Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a Monitor port");
}
}
}
[Fact]
public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList()
{
string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "Monitor.Protocol", "Monitor.Backend.Analysis"];
foreach (Project project in LoadProjects().Where(p => nodeSide.Contains(p.Name)))
{
Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers));
}
foreach (string name in new[] { "Fleet.Core", "Fleet.Identity", "Fleet.Rpc" })
{
Project project = LoadProjects().Single(p => p.Name == name);
Assert.Contains("BannedSymbols.Node.txt", File.ReadAllText(project.Path), StringComparison.Ordinal);
}
}
[Fact]
public void Protocol_DependsOnlyOnFleetCoreAndRpc_AndAnalysisOnlyOnProtocol()
{
List<Project> projects = LoadProjects();
Assert.Equal(["Fleet.Core", "Fleet.Rpc"], projects.Single(p => p.Name == "Monitor.Protocol").ProjectReferences.Order().ToArray());
Assert.Equal(["Monitor.Protocol"], projects.Single(p => p.Name == "Monitor.Backend.Analysis").ProjectReferences.ToArray());
}
[Fact]
public void NodeProcesses_AreCollectorsOnly_NoBackendNoServerLibrariesNoRuntimePackages()
{
List<Project> projects = LoadProjects();
Project Get(string name) => projects.Single(p => p.Name == name);
// Agent 是纯采集进程:不引用后端的任何层,也不引用 Fleet 的服务端库
Project agent = Get("Monitor.Agent");
Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("Monitor.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal));
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], agent.ProjectReferences.Order().ToArray());
// Probe 同理,并且不认识 Backend 和 Agent
Project probe = Get("Monitor.Probe");
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], probe.ProjectReferences.Order().ToArray());
// 两个节点的共享库只依赖协议
Assert.Equal(["Monitor.Protocol"], Get("Monitor.Measurement").ProjectReferences.ToArray());
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "Monitor.Measurement", "Monitor.Agent", "Monitor.Probe" })
{
Project project = Get(name);
Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers));
string settings = File.ReadAllText(project.Path);
for (string? dir = project.Directory; dir is not null && dir.StartsWith(s_root, StringComparison.Ordinal); dir = Path.GetDirectoryName(dir))
{
string props = Path.Combine(dir, "Directory.Build.props");
settings += File.Exists(props) ? File.ReadAllText(props) : "";
}
Assert.Contains("BannedSymbols.Node.txt", settings, StringComparison.Ordinal);
}
}
[Fact]
public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns()
{
// 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列;
// REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。
string[] collectionRoots = ["shared/Monitor.Protocol", "shared/Monitor.Measurement", "agent/src", "probe/src", "fleet/src"];
string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"];
foreach (string root in collectionRoots)
{
string directory = Path.Combine(s_root, root.Replace('/', Path.DirectorySeparatorChar));
foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
{
string text = File.ReadAllText(file);
foreach (string word in forbidden)
{
Assert.False(text.Contains(word, StringComparison.OrdinalIgnoreCase), $"{file} mentions '{word}': business concepts belong to the backend only");
}
}
}
// 派生量(丢包率、RTT、抖动)由 Backend 的分析层算:采集层的积木不引用分析层
Assert.DoesNotContain(LoadProjects().Single(p => p.Name == "Monitor.Measurement").ProjectReferences, r => r.Contains("Analysis", StringComparison.Ordinal));
}
[Fact]
public void BackendLayers_ObeyTheDependencyRules()
{
List<Project> projects = LoadProjects();
Project Get(string name) => projects.Single(p => p.Name == name);
// Api 不能引用 Storage(也不引用 Gateway):只调用 Application
Assert.DoesNotContain(Get("Monitor.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
Assert.Equal(["Monitor.Backend.Application"], Get("Monitor.Backend.Api").ProjectReferences.ToArray());
// Application 不依赖任何下游层
Assert.DoesNotContain(Get("Monitor.Backend.Application").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
// Gateway 只通过 Application 与 Fleet.Server 工作
Assert.DoesNotContain(Get("Monitor.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal));
// Storage 实现 Application 的端口,不认识 Api 和 Gateway
Assert.DoesNotContain(Get("Monitor.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
// 只有宿主(组合根)认识所有层
Assert.Equivalent(
new[] { "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Storage.Sqlite" },
Get("Monitor.Backend").ProjectReferences.ToArray());
}
[Fact]
public void BackendPackages_AreLimitedToTheDocumentedSet()
{
string[] allowed =
[
"Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "Microsoft.AspNetCore.OpenApi",
"Microsoft.Extensions.ApiDescription.Server", "xunit.v3", "Microsoft.Extensions.TimeProvider.Testing",
];
foreach (Project project in LoadProjects())
{
Assert.All(project.Packages, package => Assert.Contains(package, allowed));
}
}
[Fact]
public void ApplicationAndApi_NeverTouchSqliteOrTheFileSystemDirectly()
{
foreach (string layer in new[] { "Monitor.Backend.Application", "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Analysis" })
{
string dir = Path.Combine(s_root, "src", layer);
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
{
string text = File.ReadAllText(file);
Assert.DoesNotContain("Microsoft.Data.Sqlite", text, StringComparison.Ordinal);
Assert.DoesNotContain("File.WriteAll", text, StringComparison.Ordinal);
Assert.DoesNotContain("new FileStream", text, StringComparison.Ordinal);
}
}
}
[Fact]
public void NoProductionCode_BuildsSqlByConcatenatingValues()
{
// 手写的参数化 SQL:只有表名可以来自常量;值一律通过参数
string dir = Path.Combine(s_root, "src", "Monitor.Backend.Storage.Sqlite");
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
{
string text = File.ReadAllText(file);
Assert.DoesNotContain("+ id +", text, StringComparison.Ordinal);
Assert.DoesNotContain("+ name +", text, StringComparison.Ordinal);
Assert.DoesNotContain("+ agent", text, StringComparison.Ordinal);
Assert.DoesNotContain("'\" +", text, StringComparison.Ordinal);
}
}
[Fact]
public void EveryProject_ImportsTheSharedBuildSettings()
{
// backend/ 下的 Directory.Build.props 对所有项目生效:严格的警告、BannedSymbols、.editorconfig 的风格规则
string props = File.ReadAllText(Path.Combine(s_root, "Directory.Build.props"));
Assert.Contains("<TreatWarningsAsErrors>true</TreatWarningsAsErrors>", props, StringComparison.Ordinal);
Assert.Contains("<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>", props, StringComparison.Ordinal);
Assert.Contains("BannedSymbols.txt", props, StringComparison.Ordinal);
Assert.Contains("DateTime.UtcNow", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.txt")), StringComparison.Ordinal);
Assert.Contains("System.Diagnostics.Process", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.Node.txt")), StringComparison.Ordinal);
}
}
@@ -0,0 +1,229 @@
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 Monitor.Backend.Application;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
/// </summary>
public class BackendFixture : IAsyncLifetime
{
public const string AdminUser = "admin";
public const string AdminPassword = "correct horse battery";
private readonly List<AgentSimulator> _agents = [];
private string _adminToken = "";
private int _counter;
/// <summary>所有测试都从同一个回环地址登录,所以共享的后端放宽限流;专门测试限流的类用 <see cref="StrictLoginFixture"/>。</summary>
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!;
/// <summary>带 admin 作用域的 API 令牌的客户端。</summary>
public HttpClient Admin { get; private set; } = null!;
public IServiceProvider Services => App.Services;
/// <summary>夹具创建的默认 Probe:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
public string DefaultProbeId { get; private set; } = "";
/// <summary>默认 Probe 与这个 Agent 组成的线路 id(<c>agent~probe</c>)。</summary>
public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}";
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
/// <summary>在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。</summary>
public async Task<JsonNode> 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(), "monitor-tests-" + Guid.NewGuid().ToString("N"));
await StartAsync();
_adminToken = await CreateAdminTokenAsync();
CreateClients();
DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue<string>();
}
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)
{
// 临时目录清理失败不影响测试结果
}
}
/// <summary>重启后端(保留数据目录):用来验证数据是否真的落盘。</summary>
public async Task RestartAsync()
{
Anonymous.Dispose();
Admin.Dispose();
await StopAsync();
await StartAsync();
CreateClients(); // 新的端口:HttpClient 的 BaseAddress 只能在第一次请求之前设置
}
/// <summary>用同一个身份重新连接一个已经断开的假 Agent。</summary>
public async Task<AgentSimulator> ReconnectAsync(AgentSimulator previous)
{
AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId);
agent.ProbeForChunks = new Monitor.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<string> 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<string>();
}
/// <summary>注册并连接一个假 Agent。</summary>
public async Task<AgentSimulator> 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 Monitor.Protocol.ProbeId(DefaultProbeId);
_agents.Add(agent);
await agent.ConnectAsync(BaseAddress, hello, kinds);
return agent;
}
public async Task<AgentSimulator> 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<JsonNode> 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();
}
/// <summary>轮询直到条件成立(后台分析、状态推送都是异步的)。</summary>
public static async Task EventuallyAsync(Func<Task<bool>> 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;
});
await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseAddress = new Uri(address);
}
private async Task StopAsync()
{
await App.StopAsync();
await App.DisposeAsync();
}
private async Task<string> 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<string>());
using HttpResponseMessage created = await session.SendAsync(request);
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
}
/// <summary>登录限流为每分钟 10 次(默认值)的独立后端。</summary>
public sealed class StrictLoginFixture : BackendFixture
{
protected override int LoginAttemptsPerMinute => 10;
}
@@ -0,0 +1,399 @@
using System.Net;
using System.Text.Json.Nodes;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class EnrollmentAndTaskSetTests
{
private readonly BackendFixture _backend;
public EnrollmentAndTaskSetTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonNode> AgentAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + id);
[Fact]
public async Task NewAgent_EnrollsConnectsAndReceivesTheDefaultLineTasks()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
Assert.Equal("active", agent.EnrollmentStatus);
Assert.Equal("active", agent.AuthStatus);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
Assert.Equal(["tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
Assert.All(set.Tasks, t =>
{
Assert.Equal("line", t.Scope);
Assert.Equal(TaskSchedule.Continuous, t.Schedule);
Assert.NotEmpty(t.Params); // 参数已经补全了默认值
});
Assert.Equal(10, set.Tasks.Single(t => t.Kind == "udp_echo").Params["rate_hz"].GetInt64());
Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_echo").Params["port"].GetInt64());
Assert.True(set.Version >= 1);
JsonNode view = await AgentAsync(agent.NodeId.Value);
Assert.True(view["online"]!.GetValue<bool>());
Assert.Equal("203.0.113.10", view["public_host"]!.GetValue<string>());
Assert.Equal("0.0.1-sim", view["agent_version"]!.GetValue<string>());
Assert.Equal(200, view["local_limits"]!["max_total_mbps"]!.GetValue<double>());
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["applied_task_version"]!.GetValue<long>() == set.Version);
}
[Fact]
public async Task TaskSet_OnlyContainsKindsTheAgentSupports()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["udp_echo"]);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 1);
Assert.Equal(["udp_echo"], set.Tasks.Select(t => t.Kind).ToArray());
}
[Fact]
public async Task Reconnecting_AlwaysGetsTheCurrentSnapshotAgain()
{
AgentSimulator first = await _backend.ConnectAgentAsync();
TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await first.DisposeAsync();
// 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本
await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(first.NodeId.Value))["online"]!.GetValue<bool>());
Assert.NotNull(before);
}
[Fact]
public async Task Enrollment_RejectsBadTokens_ExhaustedTokens_AndBadProofs()
{
using HttpResponseMessage unknown = await PostEnrollAsync(new JsonObject { ["join_token"] = "jt_nope", ["name"] = "x", ["pubkey"] = "AAAA", ["tls_spki"] = "AAAA", ["proof"] = "AAAA" });
Assert.True(unknown.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest);
string token = await _backend.CreateJoinTokenAsync(maxUses: 1);
await using AgentSimulator first = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("once"));
InvalidOperationException reuse = await Assert.ThrowsAsync<InvalidOperationException>(() => AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("twice")));
Assert.Contains("401", reuse.Message);
Assert.Contains("fleet_join_token_invalid", reuse.Message);
// 吊销之后不能再用
string revoked = await _backend.CreateJoinTokenAsync(maxUses: 5);
JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
string id = tokens["items"]!.AsArray().First(t => t!["revoked_at"] is null && t["max_uses"]!.GetValue<int>() == 5)!["id"]!.GetValue<string>();
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id);
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
await Assert.ThrowsAsync<InvalidOperationException>(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked")));
}
[Fact]
public async Task Enrollment_RejectsAProofSignedByAnotherKey()
{
string token = await _backend.CreateJoinTokenAsync();
using var other = Fleet.Identity.NodeIdentity.Generate();
using var mine = Fleet.Identity.NodeIdentity.Generate();
string pubkey = Convert.ToBase64String(mine.PublicKeySpki);
string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
string forged = Convert.ToBase64String(other.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey));
using HttpResponseMessage response = await PostEnrollAsync(new JsonObject
{
["join_token"] = token,
["name"] = _backend.UniqueName("forged"),
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(new byte[32]),
["proof"] = forged,
});
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("fleet_proof_invalid", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
// 令牌没有被消耗:用正确的证明仍然可以注册
await using AgentSimulator ok = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("honest"));
Assert.Equal("active", ok.EnrollmentStatus);
}
[Fact]
public async Task ConnectionWithAWrongSignature_IsRejected_AndLeaksNothing()
{
await using AgentSimulator real = await _backend.EnrollOnlyAsync();
await using AgentSimulator stranger = await _backend.EnrollOnlyAsync();
// stranger 用自己的私钥冒充 real 的节点 ID:签名对不上
AuthAttempt impostor = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => stranger.SignAuth(real.NodeId, nonce));
Assert.False(impostor.Ok);
Assert.Equal("fleet_unauthorized", impostor.ErrorCode);
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在
var ghost = new Fleet.Core.NodeId("agt_does_not_exist");
AuthAttempt unknown = await AgentWire.AuthenticateAsync(_backend.BaseAddress, ghost, nonce => stranger.SignAuth(ghost, nonce));
Assert.Equal(impostor.ErrorCode, unknown.ErrorCode);
// 正主仍然可以连接
AuthAttempt genuine = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => real.SignAuth(real.NodeId, nonce));
Assert.True(genuine.Ok);
}
[Fact]
public async Task PendingAgent_ConnectsButGetsNoStateAndItsChunksAreIgnored_UntilApproved()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
Assert.Equal("pending", agent.EnrollmentStatus);
Assert.Equal("pending", agent.AuthStatus);
await Task.Delay(300);
Assert.Empty(agent.TaskSets);
RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 30_000);
chunk.Header.AgentId = agent.NodeId;
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk)));
await Task.Delay(300);
JsonNode chunks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.NodeId.Value);
Assert.Empty(chunks["items"]!.AsArray());
using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/approve", null);
Assert.Equal(HttpStatusCode.OK, approve.StatusCode);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
Assert.NotNull(set);
}
[Fact]
public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/disable", null);
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
await agent.WaitUntilAsync(() => agent.Revoked);
await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(agent.NodeId.Value))["online"]!.GetValue<bool>());
AuthAttempt again = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => agent.SignAuth(agent.NodeId, nonce));
Assert.False(again.Ok);
Assert.Equal("fleet_node_disabled", again.ErrorCode);
using HttpResponseMessage enable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/enable", null);
Assert.Equal(HttpStatusCode.OK, enable.StatusCode);
}
[Fact]
public async Task SameNameEnrollment_GoesPending_ApprovalConflicts_AndTakeoverReplacesTheKey()
{
string name = _backend.UniqueName("jp");
await using AgentSimulator original = await _backend.ConnectAgentAsync(name);
await using AgentSimulator replacement = await _backend.EnrollOnlyAsync(name);
Assert.Equal("pending", replacement.EnrollmentStatus);
using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/approve", null);
Assert.Equal(HttpStatusCode.Conflict, approve.StatusCode);
Assert.Equal("name_conflict", JsonNode.Parse(await approve.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage takeover = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/takeover", null);
Assert.Equal(HttpStatusCode.OK, takeover.StatusCode);
Assert.Equal(original.NodeId.Value, JsonNode.Parse(await takeover.Content.ReadAsStringAsync())!["id"]!.GetValue<string>()); // 旧 ID 保留,历史数据不丢
using HttpResponseMessage pendingGone = await _backend.Admin.GetAsync("/api/admin/agents/" + replacement.NodeId.Value);
Assert.Equal(HttpStatusCode.NotFound, pendingGone.StatusCode);
await original.WaitUntilAsync(() => original.Revoked); // 旧密钥的连接被断开
}
[Fact]
public async Task Patch_RenamesWithConflictDetection_AndHonoursIfMatch()
{
await using AgentSimulator a = await _backend.ConnectAgentAsync();
await using AgentSimulator b = await _backend.ConnectAgentAsync();
JsonNode view = await AgentAsync(b.NodeId.Value);
string name = view["name"]!.GetValue<string>();
using HttpResponseMessage conflict = await _backend.Admin.PatchAsync(
$"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = name }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
using HttpResponseMessage get = await _backend.Admin.GetAsync("/api/admin/agents/" + a.NodeId.Value);
string etag = get.Headers.ETag!.Tag;
using var stale = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk") }, "application/merge-patch+json") };
stale.Headers.TryAddWithoutValidation("If-Match", "\"999999\"");
using HttpResponseMessage precondition = await _backend.Admin.SendAsync(stale);
Assert.Equal(HttpStatusCode.PreconditionFailed, precondition.StatusCode);
using var fresh = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk"), ["public_visible"] = false }, "application/merge-patch+json") };
fresh.Headers.TryAddWithoutValidation("If-Match", etag);
using HttpResponseMessage ok = await _backend.Admin.SendAsync(fresh);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
JsonNode updated = JsonNode.Parse(await ok.Content.ReadAsStringAsync())!;
Assert.Equal("hk", updated["labels"]![0]!.GetValue<string>());
Assert.False(updated["public_visible"]!.GetValue<bool>());
Assert.NotEqual(etag, ok.Headers.ETag!.Tag);
using HttpResponseMessage invalid = await _backend.Admin.PatchAsync(
$"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = "bad name!" }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
Assert.NotNull(JsonNode.Parse(await invalid.Content.ReadAsStringAsync())!["errors"]!["name"]);
}
[Fact]
public async Task TaskOverride_ChangesTheDesiredStateAndBumpsTheVersion()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line",
["kind"] = "udp_echo",
["agent_id"] = agent.NodeId.Value,
["base_task_id"] = udp.Id.Value,
["params"] = new JsonObject { ["rate_hz"] = 20 },
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString());
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20));
Assert.Equal(2, updated.Tasks.Count);
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64());
// 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响)
string overrideId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo"));
Assert.Single(disabled.Tasks);
}
[Fact]
public async Task Tasks_ValidateParamsKindsBudgetAndTargets()
{
using HttpResponseMessage badParams = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 },
}));
Assert.Equal(HttpStatusCode.BadRequest, badParams.StatusCode);
JsonNode problem = JsonNode.Parse(await badParams.Content.ReadAsStringAsync())!;
Assert.Contains("between 1 and 200", problem["errors"]!["params.rate_hz"]![0]!.GetValue<string>());
Assert.NotNull(problem["errors"]!["params.bogus"]);
using HttpResponseMessage unknownKind = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "warp_drive" }));
Assert.Equal(HttpStatusCode.NotFound, unknownKind.StatusCode);
Assert.Equal("unknown_kind", JsonNode.Parse(await unknownKind.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage overBudget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 },
}));
Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode);
Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_echo" }));
Assert.Equal(HttpStatusCode.BadRequest, wrongScope.StatusCode);
// 目标任务:只接受 IP(不做 DNS 解析),需要指定 Agent
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
using HttpResponseMessage dns = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "example.com",
}));
Assert.Equal(HttpStatusCode.BadRequest, dns.StatusCode);
using HttpResponseMessage target = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1",
}));
Assert.Equal(HttpStatusCode.Created, target.StatusCode);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target"));
Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target").Target);
using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["schedule"] = "every:30" }));
Assert.Equal(HttpStatusCode.BadRequest, onceSchedule.StatusCode);
}
[Fact]
public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
agent.RejectNextState = [new Fleet.Rpc.RejectedItem { Id = "tsk_x", Reason = "policy_denied" }];
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443",
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["rejected"]!.AsArray().Count == 1);
Assert.Equal("policy_denied", (await AgentAsync(agent.NodeId.Value))["rejected"]![0]!["reason"]!.GetValue<string>());
}
[Fact]
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
JsonNode identity = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string id = identity["probe_id"]!.GetValue<string>();
using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>()));
Assert.Contains(identity["bootstrap"]!.AsArray(), b => b!["agent_id"]!.GetValue<string>() == agent.NodeId.Value);
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key1.PublicKeySpki)));
// The panel lists it; two probes can coexist and a duplicate name is refused
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue<string>();
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = name }));
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
// Rename
using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" }));
Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>());
// Rotate: same id, new key; the old key leaves every agent's list
using HttpResponseMessage rotated = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
JsonNode regenerated = JsonNode.Parse(await rotated.Content.ReadAsStringAsync())!;
Assert.Equal(id, regenerated["probe_id"]!.GetValue<string>());
Assert.NotEqual(identity["private_key"]!.GetValue<string>(), regenerated["private_key"]!.GetValue<string>());
using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue<string>()));
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key2.PublicKeySpki)));
// Revoke: gone from the peer list, kept in the panel as revoked; it cannot be rotated any more
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null);
Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>());
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id));
using HttpResponseMessage rotateRevoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
Assert.Equal(HttpStatusCode.Conflict, rotateRevoked.StatusCode);
// Delete
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
}
[Fact]
public async Task ProbeAgentList_IsServedToAgents_AndPushedWhenTheSetOfAgentsChanges()
{
await using AgentSimulator first = await _backend.ConnectAgentAsync();
ProbeAgentsResult list = await first.RequestProbeAgentsAsync();
Assert.Contains(list.Agents, a => a.AgentId == first.NodeId && a.Host == "203.0.113.10" && a.TcpPort == 7443);
Assert.All(list.Agents, a => Assert.Equal(32, Convert.FromBase64String(a.Spki).Length));
await using AgentSimulator second = await _backend.ConnectAgentAsync();
ProbeAgentsResult pushed = await WaitForAsync(first, l => l.Agents.Any(a => a.AgentId == second.NodeId));
Assert.True(pushed.Version > list.Version);
using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{second.NodeId.Value}/disable", null);
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
await WaitForAsync(first, l => l.Agents.All(a => a.AgentId != second.NodeId));
}
private static async Task<ProbeAgentsResult> WaitForAsync(AgentSimulator agent, Func<ProbeAgentsResult, bool> predicate)
{
ProbeAgentsResult? found = null;
await BackendFixture.EventuallyAsync(() =>
{
found = agent.ProbeAgentLists.ToArray().LastOrDefault(predicate);
return Task.FromResult(found is not null);
});
return found!;
}
private Task<HttpResponseMessage> PostEnrollAsync(JsonNode body) =>
_backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body));
}
@@ -0,0 +1,114 @@
using System.Text.Json.Nodes;
using Monitor.Agent;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
public sealed class HostSamplerTests
{
private const string Stat1 = "cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 0 0 0 0 0\n";
private const string Stat2 = "cpu 200 0 200 800 100 0 0 0 0 0\n"; // 比上一次:总时间 +300,其中空闲 +100
private const string Dev1 = "Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 10 0 0 0 0 0 0 5000 10 0 0 0 0 0 0\ndocker0: 777 1 0 0 0 0 0 0 777 1 0 0 0 0 0 0\n";
private const string Dev2 = "Inter-| Receive\n face |bytes\n eth0: 3000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0\n";
[Fact]
public void Parsers_ReadTheProcFormats_AndSkipLoopbackAndContainerInterfaces()
{
Assert.Equal((1000L, 800L), HostSampler.ParseCpu(Stat1));
Assert.Equal((8L * 1024 * 1024, 2L * 1024 * 1024), HostSampler.ParseMemory("MemTotal: 8192 kB\nMemFree: 100 kB\nMemAvailable: 6144 kB\n"));
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1));
Assert.Equal(3600L, HostSampler.ParseUptime("3600.55 7000.1\n"));
Assert.Null(HostSampler.ParseNetwork("nothing useful"));
}
[Fact]
public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads()
{
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
string stat = Stat1;
string dev = Dev1;
var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250));
HostSnapshot first = sampler.Sample();
Assert.Null(first.CpuPercent); // 第一次没有基线
Assert.Null(first.NetRxBps);
Assert.Equal(250, first.DiskUsed);
Assert.Equal(100, first.UptimeSeconds);
time.Advance(TimeSpan.FromSeconds(2));
stat = Stat2;
dev = Dev2;
HostSnapshot second = sampler.Sample();
Assert.Equal(66.7, second.CpuPercent); // 总时间 +300、空闲 +100:忙碌 200/300
Assert.Equal(1000, second.NetRxBps); // (3000 - 1000) / 2 s
Assert.Equal(2000, second.NetTxBps);
Assert.Equal(3000, second.NetRxTotal);
}
}
[Collection("backend")]
public sealed class HostStatusTests
{
private readonly BackendFixture _backend;
public HostStatusTests(BackendFixture backend)
{
_backend = backend;
}
private static HostSnapshot Snapshot(double cpu) => new()
{
CpuPercent = cpu, MemUsed = 2L << 30, MemTotal = 8L << 30, DiskUsed = 10L << 30, DiskTotal = 40L << 30,
NetRxTotal = 600L << 30, NetTxTotal = 180L << 30, NetRxBps = 2500, NetTxBps = 4800, UptimeSeconds = 3_000_000,
};
[Fact]
public async Task AgentHostSnapshots_AreVisibleToTheDisplayInRealTime_StoredAsHistory_AndNeverCarryModelsOrAddresses()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
await agent.SendHostAsync(Snapshot(18.5));
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["agent_id"]!.GetValue<string>() == id);
Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue<double>());
Assert.True(live.Data["online"]!.GetValue<bool>());
// REST:最新值,没有过期
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["agent_id"]!.GetValue<string>() == id)!;
Assert.False(item["stale"]!.GetValue<bool>());
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
// 展示页能拿到的只有汇总数字:整份响应里没有型号、主机名、地址之类的字段
string text = list.ToJsonString();
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
{
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
}
// 线路详情也不泄漏 Probe 的地址和版本
string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString();
Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("probe_version", line, StringComparison.OrdinalIgnoreCase);
// 历史:每分钟一个样本
JsonNode history = null!;
await BackendFixture.EventuallyAsync(async () =>
{
history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/agents/{id}/host?range=1h");
return history["values"]!["cpu"]!.AsArray().Any(v => v is not null);
});
Assert.Equal(25.0, history["values"]!["mem"]!.AsArray().Last(v => v is not null)!.GetValue<double>());
Assert.Equal(2500, history["values"]!["rx_bps"]!.AsArray().Last(v => v is not null)!.GetValue<double>());
// 隐藏的 Agent 对匿名访客不可见
await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false }));
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["agent_id"]!.GetValue<string>() == id);
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/agents/{id}/host");
Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode);
}
}
@@ -0,0 +1,408 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Fleet.Core;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class IngestAndQueryTests
{
private const long Minute = 60_000;
private readonly BackendFixture _backend;
public IngestAndQueryTests(BackendFixture backend)
{
_backend = backend;
}
/// <summary>当前分钟的起点:测试数据放在已经结束的分钟里,这样持久路径会立即计算这些窗口。</summary>
private static long ClosedMinuteStart(int minutesAgo) =>
((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / Minute) - minutesAgo) * Minute;
/// <summary>把 [baseMs, baseMs + chunks × 10 s) 的数据送给后端:每个块 10 s、100 个包;每 <paramref name="lossEvery"/> 个包丢一个回复。</summary>
private static async Task SendLineAsync(AgentSimulator agent, long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20)
{
for (int k = 0; k < chunks; k++)
{
long start = baseMs + (k * 10_000L);
RawChunk udp = ChunkFactory.Echo(
agent.NodeId, "udp_echo", start, 100, 100,
i => new EchoPacket(false, lossEvery > 0 && i % lossEvery == 0, (long)(owdMs * 1000), (long)(owdMs * 1000)));
Assert.True(await agent.SendChunkAsync(udp));
if (tcp)
{
long probeBase = ChunkFactory.ProbeUs(0);
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (probeBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
RawChunk tcpChunk = ChunkFactory.Echo(
agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]);
Assert.True(await agent.SendChunkAsync(tcpChunk));
}
}
}
private async Task<(AgentSimulator Agent, string Name)> LineAsync(long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20)
{
AgentSimulator agent = await _backend.ConnectAgentAsync();
await SendLineAsync(agent, baseMs, chunks, lossEvery, tcp, owdMs);
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["name"]!.GetValue<string>();
return (agent, name);
}
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
private async Task<JsonNode> SeriesAsync(string agentId, string query) =>
await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agentId)}/series?{query}");
[Fact]
public async Task Line_ReportsWhenItsDataStarted_SoTheDisplayOnlyOffersRangesThatHaveData()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(30);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6);
await using (agent)
{
JsonNode before = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}");
DateTimeOffset since = before["data_since"]!.GetValue<DateTimeOffset>();
// 精确到秒(细的层);如果细的层已经被保留策略清掉,只剩 5 分钟层,起点最多早 5 分钟
Assert.InRange(since.ToUnixTimeMilliseconds(), baseMs - 300_000, baseMs + 70_000);
// 一条没有任何数据的线路:没有 data_since
await using AgentSimulator empty = await _backend.ConnectAgentAsync();
JsonNode none = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(empty.NodeId.Value)}");
Assert.Null(none["data_since"]);
}
}
[Fact]
public async Task Series_AnchorLatest_ShowsTheLastWindowOfALineThatHasStoppedReporting()
{
long baseMs = ClosedMinuteStart(20);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6, tcp: false); // one minute of data, 20 minutes ago
await using (agent)
{
string id = agent.NodeId.Value;
// Anchored at now: the last 5 minutes are empty
JsonNode atNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total");
Assert.Null(atNow["summary"]!["samples"]!["udp_echo"]);
// Anchored at the latest sample: the window ends where the data ends and carries all of it
JsonNode anchored = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=latest");
long to = DateTimeOffset.Parse(anchored["to"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture).ToUnixTimeMilliseconds();
Assert.InRange(to, baseMs + (50 * 1000), baseMs + (80 * 1000));
Assert.Equal(600, anchored["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.False(anchored["live"]!.GetValue<bool>());
// anchor=now is the default behaviour; anything else is rejected
JsonNode explicitNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=now");
Assert.Null(explicitNow["summary"]!["samples"]!["udp_echo"]);
using HttpResponseMessage bad = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&anchor=yesterday");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, bad.StatusCode);
}
}
[Fact]
public async Task Chunks_AreStoredAndAcknowledged_DuplicatesAndGarbageAreDiscardedWithAReason()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
long start = ClosedMinuteStart(3);
RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, start);
Assert.True(await agent.SendChunkAsync(chunk), "the first delivery is stored");
Assert.False(await agent.SendChunkAsync(chunk), "the second delivery is acknowledged as a duplicate");
// 损坏的块:确认掉(否则节点会无限重发)并记入事件
var garbage = new ChunkId(Guid.CreateVersion7());
var ack = new TaskCompletionSource<bool>();
byte[] corrupt = RawChunkCodec.Encode(ChunkFactory.UdpEchoClean(agent.NodeId, start));
corrupt[^1] ^= 0xFF;
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, garbage.Value, corrupt));
// 冒名的块:agent_id 与认证的节点不一致
RawChunk spoofed = ChunkFactory.UdpEchoClean(new NodeId("agt_someone_else"), start);
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, spoofed.Header.ChunkId.Value, RawChunkCodec.Encode(spoofed)));
await BackendFixture.EventuallyAsync(async () =>
{
JsonNode events = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected");
return events["items"]!.AsArray().Count >= 2;
});
JsonNode rejected = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected");
string details = string.Join('|', rejected["items"]!.AsArray().Select(e => e!["detail"]!.GetValue<string>()));
Assert.Contains("undecodable chunk", details);
Assert.Contains("does not match the authenticated node", details);
JsonNode stored = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}");
Assert.Single(stored["items"]!.AsArray());
}
[Fact]
public async Task Series_HotAndPersistedLayersGiveTheSameExactSummary()
{
long baseMs = ClosedMinuteStart(4);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 18, lossEvery: 10);
await using (agent)
{
long to = baseMs + (3 * Minute);
string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp,tcp";
JsonNode hot = await SeriesAsync(agent.NodeId.Value, range + "&step=1s");
Assert.Equal("hot", hot["source"]!.GetValue<string>());
Assert.Equal(180, hot["t"]!.AsArray().Count);
JsonNode minute = null!;
await BackendFixture.EventuallyAsync(async () =>
{
minute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
// 窗口会随着后续的块到达而重算:等到 18 个块的样本都计入之后再比较
return minute["source"]!.GetValue<string>() == "1m" && minute["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1800
&& minute["summary"]!["samples"]!["tcp_echo"]?.GetValue<long>() == 3600;
});
JsonNode hotSummary = hot["summary"]!["metrics"]!;
JsonNode minuteSummary = minute["summary"]!["metrics"]!;
Assert.Equal(10.0, hotSummary["loss.total"]!.GetValue<double>(), 6);
foreach (string id in new[] { "loss.total", "loss.up", "loss.down", "rtt.p50", "rtt.p95", "rtt.p99", "tcp.rtt.p95", "tcp.retrans", "jitter.down.p95" })
{
Assert.True(
hotSummary[id] is null == minuteSummary[id] is null && (hotSummary[id] is null || Math.Abs(hotSummary[id]!.GetValue<double>() - minuteSummary[id]!.GetValue<double>()) < 1e-9),
$"{id}: hot={hotSummary[id]} persisted={minuteSummary[id]}");
}
Assert.Equal(1800, hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>(), minute["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_echo"]!.GetValue<long>());
// RTT = 2 × 20 ms,直方图的相对误差 ≤ 4.5%
Assert.InRange(minuteSummary["rtt.p95"]!.GetValue<double>(), 40 * 0.955, 40 * 1.045);
Assert.Equal(3, minute["t"]!.AsArray().Count);
Assert.Equal(1.0, minute["summary"]!["coverage"]!.GetValue<double>(), 3);
// 汇总是精确合并,不是各点的平均:每个分钟桶的丢包率都是 10%,整体也是 10%
Assert.All(minute["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(10.0, v!.GetValue<double>(), 6));
}
}
[Fact]
public async Task Series_TenSecondLayer_AndTheFivePointFiveSecondStepsDegradeGracefully()
{
long baseMs = ClosedMinuteStart(3);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6);
await using (agent)
{
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&preset=udp";
JsonNode tenSeconds = await SeriesAsync(agent.NodeId.Value, range + "&step=10s");
Assert.Equal("10s", tenSeconds["source"]!.GetValue<string>());
Assert.Equal(6, tenSeconds["t"]!.AsArray().Count);
Assert.All(tenSeconds["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(0.0, v!.GetValue<double>()));
// 15 s 不能整除任何层的宽度(热层以外):对更早的数据降级到可用的最细层,并如实标记
long farBase = ClosedMinuteStart(120);
JsonNode degraded = await SeriesAsync(agent.NodeId.Value, $"from={Iso(farBase)}&to={Iso(farBase + (10 * Minute))}&step=15s&preset=udp");
Assert.True(degraded["degraded"]!.GetValue<bool>());
Assert.Equal("30s", degraded["step"]!.GetValue<string>());
}
}
[Fact]
public async Task Overview_RanksLinesByScore_AndTheLossyLineScoresLower()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(3);
(AgentSimulator good, string goodName) = await LineAsync(baseMs, 12);
(AgentSimulator bad, string badName) = await LineAsync(baseMs, 12, lossEvery: 5, owdMs: 60);
await using (good)
await using (bad)
{
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&preset=udp,scores";
JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range);
JsonNode[] lines = [.. overview["lines"]!.AsArray().Select(l => l!)];
int goodIndex = Array.FindIndex(lines, l => l["name"]!.GetValue<string>() == goodName);
int badIndex = Array.FindIndex(lines, l => l["name"]!.GetValue<string>() == badName);
Assert.True(goodIndex >= 0 && badIndex >= 0);
Assert.True(goodIndex < badIndex, "the clean line must rank above the lossy one (default sort: first requested score, descending)");
double goodScore = lines[goodIndex]["summary"]!["metrics"]!["score.base"]!.GetValue<double>();
double badScore = lines[badIndex]["summary"]!["metrics"]!["score.base"]!.GetValue<double>();
Assert.True(goodScore > 90, $"clean line scored {goodScore}");
Assert.True(badScore < goodScore - 30, $"lossy line scored {badScore}");
Assert.Equal(20.0, lines[badIndex]["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
// 按指标排序:loss.total 升序时好线路在前
JsonNode byLoss = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range + "&sort=loss.total&order=asc");
string[] names = [.. byLoss["lines"]!.AsArray().Select(l => l!["name"]!.GetValue<string>())];
Assert.True(Array.IndexOf(names, goodName) < Array.IndexOf(names, badName));
}
}
[Fact]
public async Task Compare_Distribution_AndHeatmap_AreConsistentWithTheSeries()
{
long baseMs = ClosedMinuteStart(3);
(AgentSimulator a, _) = await LineAsync(baseMs, 12, owdMs: 20);
(AgentSimulator b, _) = await LineAsync(baseMs, 12, owdMs: 40);
await using (a)
await using (b)
{
string window = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}";
JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={_backend.Line(a.NodeId.Value)},{_backend.Line(b.NodeId.Value)}&metric=rtt.p50&{window}&step=10s");
Assert.Equal(12, compare["t"]!.AsArray().Count);
double rttA = compare["summary"]![_backend.Line(a.NodeId.Value)]!.GetValue<double>();
double rttB = compare["summary"]![_backend.Line(b.NodeId.Value)]!.GetValue<double>();
Assert.InRange(rttA, 40 * 0.955, 40 * 1.045);
Assert.InRange(rttB, 80 * 0.955, 80 * 1.045);
JsonNode distribution = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=rtt&{window}");
Assert.Equal(1200, distribution["count"]!.GetValue<long>());
Assert.Equal(distribution["count"]!.GetValue<long>(), distribution["buckets"]!.AsArray().Sum(x => x!["count"]!.GetValue<long>()));
Assert.InRange(distribution["p50"]!.GetValue<double>(), 80 * 0.955, 80 * 1.045);
// step=1m 读的是持久层,由后台分析异步算出:等它完整
JsonNode heatmap = null!;
await BackendFixture.EventuallyAsync(async () =>
{
heatmap = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/heatmap?metric=rtt&{window}&step=1m");
return heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue<long>())) == 1200;
});
Assert.Equal(2, heatmap["t"]!.AsArray().Count);
Assert.Equal(2, heatmap["counts"]!.AsArray().Count);
Assert.All(heatmap["counts"]!.AsArray(), row => Assert.Equal(heatmap["edges"]!.AsArray().Count, row!.AsArray().Count));
Assert.Equal(1200, heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue<long>())));
using HttpResponseMessage notHistogram = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=loss.up&{window}");
Assert.Equal(HttpStatusCode.UnprocessableEntity, notHistogram.StatusCode);
}
}
[Fact]
public async Task Queries_ValidateTheirParameters_WithActionableErrors()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
async Task<(HttpStatusCode Status, string Code, string Detail)> Fail(string url)
{
using HttpResponseMessage response = await _backend.Admin.GetAsync(url);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
return (response.StatusCode, problem["code"]!.GetValue<string>(), problem["detail"]?.GetValue<string>() ?? "");
}
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=abc")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&step=7s")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_metric"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&metrics=bogus.metric")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&preset=zzz")));
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&from=2026-01-01T00:00:00Z")));
Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), Trim(await Fail("/api/public/lines/agt_nope/series?range=1h")));
(HttpStatusCode, string, string Detail) tooLarge = await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=30d&step=1m");
Assert.Equal(HttpStatusCode.UnprocessableEntity, tooLarge.Item1);
Assert.Equal("range_too_large", tooLarge.Item2);
Assert.Contains("step=", tooLarge.Detail); // 给出可行的替代方案,而不是悄悄截断
// 重复的指标 id 只算一个;真正超过 32 个不同的指标才报错
string repeated = string.Join(',', Enumerable.Range(0, 40).Select(_ => "rtt.p95"));
using HttpResponseMessage deduplicated = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={repeated}");
Assert.Equal(HttpStatusCode.OK, deduplicated.StatusCode);
JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/public/catalog/metrics");
string distinct = string.Join(',', catalog["items"]!.AsArray().Select(m => m!["id"]!.GetValue<string>()).Take(40));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "too_many_metrics"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={distinct}")));
static (HttpStatusCode, string) Trim((HttpStatusCode Status, string Code, string Detail) f) => (f.Status, f.Code);
}
[Fact]
public async Task ClosedRanges_AreImmutableAndConditional_RelativeRangesAreNeverCached()
{
long baseMs = ClosedMinuteStart(10);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false);
await using (agent)
{
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=10s&preset=udp";
using HttpResponseMessage closed = await _backend.Admin.GetAsync(url);
Assert.Equal("public, max-age=31536000, immutable", closed.Headers.CacheControl!.ToString());
string etag = closed.Headers.ETag!.Tag;
using var conditional = new HttpRequestMessage(HttpMethod.Get, url);
conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
using HttpResponseMessage notModified = await _backend.Admin.SendAsync(conditional);
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
using HttpResponseMessage relative = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m&preset=udp");
Assert.Equal("no-store", relative.Headers.CacheControl!.ToString());
Assert.Null(relative.Headers.ETag);
}
}
[Fact]
public async Task HiddenLines_AreInvisibleToAnonymousVisitors_ButNotToSignedInOnes()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(3);
(AgentSimulator agent, string name) = await LineAsync(baseMs, 3, tcp: false);
await using (agent)
{
using HttpResponseMessage hide = await _backend.Admin.PatchAsync(
"/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, hide.StatusCode);
JsonNode anonymousOverview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m");
Assert.DoesNotContain(anonymousOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue<string>() == name);
using HttpResponseMessage anonymousSeries = await _backend.Anonymous.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m");
Assert.Equal(HttpStatusCode.NotFound, anonymousSeries.StatusCode); // 404,不是 403:不暴露线路是否存在
JsonNode adminOverview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=15m");
Assert.Contains(adminOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue<string>() == name);
}
}
[Fact]
public async Task LateChunks_UpdateAlreadyComputedWindows()
{
long baseMs = ClosedMinuteStart(5);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false);
await using (agent)
{
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=1m&preset=udp";
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600);
// Agent 重连后补传一个迟到的块(落在同一个分钟窗口里)
RawChunk late = ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + 5_000, 100, 10, _ => new EchoPacket(false, false, 20_000, 20_000));
Assert.True(await agent.SendChunkAsync(late));
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 700, because: "the late chunk must re-enter the persistent path");
}
}
[Fact]
public async Task RolledUpLayers_AgreeWithTheMinuteLayer()
{
long baseMs = ClosedMinuteStart(7) / (5 * Minute) * (5 * Minute); // 对齐到 5 分钟,整段都在已经结束的时间里
(AgentSimulator agent, _) = await LineAsync(baseMs, 30, lossEvery: 20, tcp: false);
await using (agent)
{
long to = baseMs + (5 * Minute);
string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp";
JsonNode oneMinute = null!;
await BackendFixture.EventuallyAsync(async () =>
{
oneMinute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
return oneMinute["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 3000;
});
// 5 分钟层由降采样把 5 个 1 分钟窗口精确合并而来
JsonNode fiveMinutes = null!;
await BackendFixture.EventuallyAsync(async () =>
{
fiveMinutes = await SeriesAsync(agent.NodeId.Value, range + "&step=5m");
return fiveMinutes["source"]!.GetValue<string>() == "5m" && fiveMinutes["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 3000;
});
Assert.Equal(oneMinute["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 9);
Assert.Equal(oneMinute["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), 9);
}
}
}
@@ -0,0 +1,136 @@
using System.Net;
using System.Text.Json.Nodes;
using Fleet.Core;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class LiveTests
{
private readonly BackendFixture _backend;
public LiveTests(BackendFixture backend)
{
_backend = backend;
}
private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
[Fact]
public async Task Stream_StartsWithASnapshot_ThenPushesNewBucketsAndNodeChanges()
{
await _backend.SiteMustBePublicAsync();
AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, $"/api/public/live?topics=nodes,series:{_backend.Line(id)}:1s:udp,tcp,overview:15m:udp");
Assert.Equal("text/event-stream", sse.ContentType);
// 快照:当前在线状态、汇总排行、该线路的序列
SseEvent node = await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue<string>() == id);
Assert.True(node.Data!["online"]!.GetValue<bool>());
await sse.WaitForAsync(e => e.Name == "overview");
await sse.WaitForAsync(e => e.Name == "series" && e.Data!["agent_id"]!.GetValue<string>() == id);
// 新块到达之后:推送新的 1s 桶(列式)和最新的汇总
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", Now() - 10_000, 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000));
Assert.True(await agent.SendChunkAsync(chunk));
SseEvent series = await sse.WaitForAsync(e => e.Name == "series" && e.Data!["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() >= 100);
Assert.True(series.Data!["live"]!.GetValue<bool>());
Assert.Equal("1s", series.Data["step"]!.GetValue<string>());
Assert.Equal(series.Data["t"]!.AsArray().Count, series.Data["values"]!["loss.total"]!.AsArray().Count);
Assert.Equal(10.0, series.Data["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
// 掉线:推送 node 事件
await agent.DisposeAsync();
await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue<string>() == id && !e.Data["online"]!.GetValue<bool>());
}
[Fact]
public async Task Stream_PushesTestStatusChanges()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
using HttpResponseMessage accepted = await _backend.Admin.PostAsync(
$"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=test:" + testId);
SseEvent initial = await sse.WaitForAsync(e => e.Name == "test");
Assert.Equal("queued", initial.Data!["status"]!.GetValue<string>());
Assert.Equal(3, initial.Data["phases"]!.GetValue<int>());
await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Running, phase: 1);
SseEvent running = await sse.WaitForAsync(e => e.Name == "test" && e.Data!["status"]!.GetValue<string>() == "running");
Assert.Equal(1, running.Data!["phase"]!.GetValue<int>());
}
[Fact]
public async Task Stream_RejectsBadTopics_UnknownLines_AndTooManyConnections()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
async Task<(HttpStatusCode, string)> Probe(string query)
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/live?" + query, HttpCompletionOption.ResponseHeadersRead);
string code = response.IsSuccessStatusCode ? "" : JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>();
return (response.StatusCode, code);
}
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe(""));
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe("topics=bogus"));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:5m:udp"));
Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), await Probe("topics=series:agt_nope:1s:udp"));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:1s:zzz"));
string many = string.Join(',', Enumerable.Range(1, 40).Select(i => $"test:tst_{i}"));
Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=" + many));
// 每个客户端最多 4 个 SSE 连接
var open = new List<SseClient>();
try
{
for (int i = 0; i < 4; i++)
{
open.Add(await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes"));
}
Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=nodes"));
}
finally
{
foreach (SseClient client in open)
{
await client.DisposeAsync();
}
}
// 连接关闭之后配额释放
await BackendFixture.EventuallyAsync(async () => (await Probe("topics=nodes")).Item1 == HttpStatusCode.OK);
}
[Fact]
public async Task Stream_FollowsTheSameVisibilityRulesAsRest()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
using HttpResponseMessage hide = await _backend.Admin.PatchAsync(
"/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, hide.StatusCode);
using HttpResponseMessage anonymous = await _backend.Anonymous.GetAsync($"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(HttpStatusCode.NotFound, anonymous.StatusCode);
await using SseClient admin = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp");
await admin.WaitForAsync(e => e.Name == "series");
// 匿名的 nodes 订阅看不到被隐藏线路的上下线
await using SseClient anonymousNodes = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes");
await Task.Delay(300);
Assert.DoesNotContain(anonymousNodes.Events, e => e.Data?["id"]?.GetValue<string>() == agent.NodeId.Value);
}
}
@@ -0,0 +1,49 @@
using System.Net;
using System.Text.Json.Nodes;
namespace Monitor.Backend.Tests;
/// <summary>登录限流按来源地址计,会影响同一地址上的其他登录,所以用独立的后端实例。</summary>
public sealed class LoginRateLimitTests : IClassFixture<StrictLoginFixture>
{
private readonly StrictLoginFixture _backend;
public LoginRateLimitTests(StrictLoginFixture backend)
{
_backend = backend;
}
[Fact]
public async Task LoginAttempts_AreRateLimited_WithRetryAfter_AndASuccessfulLoginIsAlsoCounted()
{
using HttpClient client = _backend.NewClient();
HttpResponseMessage? limited = null;
int attempts = 0;
for (; attempts < 15 && limited is null; attempts++)
{
HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "admin", ["password"] = "wrong" }));
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
limited = response;
}
else
{
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
response.Dispose();
}
}
Assert.NotNull(limited);
using (limited)
{
// 前一次登录(创建管理员令牌)也占了一次配额:第 10 次之内的都是 401
Assert.InRange(attempts, 9, 10);
Assert.Equal("too_many_requests", JsonNode.Parse(await limited.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
Assert.True(limited.Headers.RetryAfter!.Delta!.Value.TotalSeconds is >= 1 and <= 60);
}
// 被限流的来源连正确的密码也不行:限流发生在校验密码之前,不能被用来猜密码
using HttpResponseMessage correct = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
Assert.Equal(HttpStatusCode.TooManyRequests, correct.StatusCode);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1861;CA2234;CA1860</NoWarn>
<OpenApiGenerateDocuments>false</OpenApiGenerateDocuments>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<ProjectReference Include="..\..\src\Monitor.Backend\Monitor.Backend.csproj" />
<ProjectReference Include="..\Monitor.TestSupport\Monitor.TestSupport.csproj" />
<ProjectReference Include="..\..\agent\src\Monitor.Agent\Monitor.Agent.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
<ProjectReference Include="..\..\probe\src\Monitor.Probe\Monitor.Probe.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,277 @@
using System.Net;
using System.Text.Json.Nodes;
using Fleet.Core;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class OnDemandTestsTests
{
private readonly BackendFixture _backend;
public OnDemandTestsTests(BackendFixture backend)
{
_backend = backend;
}
private Task<HttpResponseMessage> StartAsync(AgentSimulator agent, JsonObject body) =>
_backend.Admin.PostAsync($"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(body));
private static JsonObject Plan(string plan, JsonObject? parameters = null) =>
parameters is null ? new JsonObject { ["plan"] = plan } : new JsonObject { ["plan"] = plan, ["params"] = parameters };
private async Task<AgentSimulator> AgentWithProbeAsync()
{
AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
return agent;
}
private async Task<JsonNode> TestAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id);
[Fact]
public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode);
Assert.Equal("probe_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
using HttpResponseMessage unknownPlan = await StartAsync(agent, Plan("warp_ladder"));
Assert.Equal(HttpStatusCode.NotFound, unknownPlan.StatusCode);
Assert.Equal("unknown_plan", JsonNode.Parse(await unknownPlan.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage badParam = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["nope"] = 1 }));
Assert.Equal(HttpStatusCode.BadRequest, badParam.StatusCode);
Assert.NotNull(JsonNode.Parse(await badParam.Content.ReadAsStringAsync())!["errors"]!["params.nope"]);
using HttpResponseMessage outOfRange = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["size"] = 9000 }));
Assert.Equal(HttpStatusCode.BadRequest, outOfRange.StatusCode);
// 超过 Agent 本地声明的码率上限(hello 里上报 200 Mbit/s)
using HttpResponseMessage overBudget = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["mbps"] = 500 }));
Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode);
Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage missingAgent = await _backend.Admin.PostAsync("/api/admin/lines/agt_nope/tests", BackendFixture.Json(Plan("hy2_ladder")));
Assert.Equal(HttpStatusCode.NotFound, missingAgent.StatusCode);
}
[Fact]
public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores()
{
await using AgentSimulator agent = await AgentWithProbeAsync();
using HttpResponseMessage accepted = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
JsonNode started = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!;
string testId = started["id"]!.GetValue<string>();
Assert.Equal("/api/public/tests/" + testId, accepted.Headers.Location!.ToString());
Assert.Equal("queued", started["status"]!.GetValue<string>());
// Agent 收到按需测试:四个阶段,码率阶梯,参数已补全默认值
await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty);
TestRunParams run = agent.TestRuns.Single();
Assert.Equal(testId, run.TestId.Value);
Assert.Equal([10d, 25d, 50d, 100d], run.Phases.Select(p => p.Params["mbps"].GetDouble()).ToArray());
Assert.All(run.Phases, p => Assert.Equal("udp_train", p.Kind));
Assert.All(run.Phases, p => Assert.Equal(1250, p.Params["size"].GetInt64()));
// 同一条线路同时只能跑一个测试
using HttpResponseMessage busy = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Conflict, busy.StatusCode);
Assert.Equal("test_busy", JsonNode.Parse(await busy.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
await agent.SendTestStatusAsync(run.TestId, TestStatuses.Running, phase: 0);
await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue<string>() == "running");
long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
double[] mbps = [10, 25, 50, 100];
int[] lossEvery = [0, 0, 5, 2]; // 50 Mbit/s 起被限速
for (int phase = 0; phase < 4; phase++)
{
RawChunk chunk = ChunkFactory.Train(agent.NodeId, now - 60_000 + (phase * 2_000), mbps[phase], phase, seconds: 2, lossEvery: lossEvery[phase]);
chunk.Header.TestId = run.TestId;
chunk.Header.Phase = phase;
Assert.True(await agent.SendChunkAsync(chunk));
await agent.SendTestStatusAsync(run.TestId, TestStatuses.PhaseDone, phase);
}
await agent.SendTestStatusAsync(run.TestId, TestStatuses.Succeeded);
JsonNode done = null!;
await BackendFixture.EventuallyAsync(async () =>
{
done = await TestAsync(testId);
return done["result"] is not null;
}, because: "the finalizer computes the result once every phase's chunks have arrived");
Assert.Equal("succeeded", done["status"]!.GetValue<string>());
JsonNode metrics = done["result"]!["metrics"]!;
Assert.Equal(25, metrics["hy2.sustainable_mbps"]!.GetValue<double>());
Assert.Equal(50, metrics["hy2.qos_onset_mbps"]!.GetValue<double>());
Assert.Equal(4, done["result"]!["steps"]!.AsArray().Count);
Assert.InRange(done["result"]!["steps"]![2]!["loss_pct"]!.GetValue<double>(), 19, 21);
Assert.Equal(new double[] { 10, 25, 50, 100 }, done["result"]!["series"]!["step_mbps"]!.AsArray().Select(v => v!.GetValue<double>()).ToArray());
JsonNode hy2 = done["result"]!["scores"]!["hy2"]!;
Assert.False(hy2["estimated"]!.GetValue<bool>());
Assert.InRange(hy2["score"]!.GetValue<double>(), 20, 90);
Assert.Contains(hy2["caveats"]!.AsArray(), c => c!.GetValue<string>() == "traffic_shape_only");
// 测试结果会进入线路的 hy2 评分(持续数据里没有这些指标)
await _backend.SiteMustBePublicAsync();
JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m&metrics=score.hy2");
JsonNode line = overview["lines"]!.AsArray().Single(l => l!["id"]!.GetValue<string>() == _backend.Line(agent.NodeId.Value))!;
Assert.NotNull(line["summary"]!["metrics"]!["score.hy2"]);
Assert.Equal(testId, line["last_test"]!["id"]!.GetValue<string>());
// 测试用的原始块不进入持续线路的指标
JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=15m&metrics=train.loss");
Assert.Null(series["summary"]!["metrics"]!["train.loss"]);
}
[Fact]
public async Task AgentRefusal_IsSurfacedAndMarksTheTestFailed()
{
await using AgentSimulator agent = await AgentWithProbeAsync();
agent.TestRunHandler = _ => (false, "test_busy");
using HttpResponseMessage response = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("test_busy", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
JsonNode tests = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests");
JsonNode latest = tests["items"]![0]!;
Assert.Equal("failed", latest["status"]!.GetValue<string>());
Assert.Equal("test_busy", latest["error_code"]!.GetValue<string>());
// 失败的测试不再占用线路
agent.TestRunHandler = _ => (true, null);
using HttpResponseMessage retry = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode);
}
[Fact]
public async Task Cancel_ReachesTheAgent_AndOnlyTheOwningAgentCanReportStatus()
{
await using AgentSimulator agent = await AgentWithProbeAsync();
await using AgentSimulator other = await _backend.ConnectAgentAsync();
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty);
// 别的 Agent 不能汇报这个测试的状态
await other.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded);
await Task.Delay(200);
Assert.Equal("queued", (await TestAsync(testId))["status"]!.GetValue<string>());
using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null);
Assert.Equal(HttpStatusCode.OK, cancel.StatusCode);
await agent.WaitUntilAsync(() => agent.TestCancels.Any(t => t.Value == testId));
await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Aborted);
await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue<string>() == "aborted");
// 终态之后的状态汇报被忽略
await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded);
await Task.Delay(200);
Assert.Equal("aborted", (await TestAsync(testId))["status"]!.GetValue<string>());
using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/tests/tst_nope/cancel", null);
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
}
[Fact]
public async Task CancellingATestOfAnOfflineAgent_AbortsItImmediately()
{
AgentSimulator agent = await AgentWithProbeAsync();
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
await agent.DisposeAsync();
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue<bool>());
using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null);
Assert.Equal(HttpStatusCode.OK, cancel.StatusCode);
Assert.Equal("aborted", JsonNode.Parse(await cancel.Content.ReadAsStringAsync())!["status"]!.GetValue<string>());
}
[Fact]
public async Task TestsOfHiddenLines_AreNotFoundForAnonymousVisitors()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await AgentWithProbeAsync();
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId);
Assert.Equal(HttpStatusCode.OK, visible.StatusCode);
using HttpResponseMessage hide = await _backend.Admin.PatchAsync(
"/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, hide.StatusCode);
using HttpResponseMessage hidden = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId);
Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode);
}
[Fact]
public async Task RawChunks_AreListedAndDownloadable_ExpiredOnesReportGone()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, now - 120_000);
Assert.True(await agent.SendChunkAsync(chunk));
JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo");
JsonNode item = list["items"]!.AsArray().Single()!;
Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue<string>());
Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue<string>());
using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId);
Assert.Equal("application/vnd.monitor.rawchunk", download.Content.Headers.ContentType!.MediaType);
RawChunk decoded = RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).Value;
Assert.Equal(chunk.Header.ChunkId, decoded.Header.ChunkId);
Assert.Equal(100, decoded.Find("msg")!.Count);
using HttpResponseMessage missing = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + Guid.CreateVersion7());
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
// UUIDv7 里的时间戳早于保留期:索引已经清理,所以是 410 而不是 404
var longAgo = Guid.CreateVersion7(TimeProvider.System.GetUtcNow().AddDays(-400));
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + longAgo);
Assert.Equal(HttpStatusCode.Gone, gone.StatusCode);
Assert.Equal("raw_expired", JsonNode.Parse(await gone.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
}
[Fact]
public async Task RawChunkList_PagesWithAStableCursor()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
long baseMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 300_000;
for (int k = 0; k < 5; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.UdpEchoClean(agent.NodeId, baseMs + (k * 10_000L))));
}
var seen = new List<string>();
string? cursor = null;
int pages = 0;
do
{
JsonNode page = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor));
seen.AddRange(page["items"]!.AsArray().Select(i => i!["chunk_id"]!.GetValue<string>()));
cursor = page["next_cursor"]?.GetValue<string>();
pages++;
}
while (cursor is not null && pages < 10);
Assert.Equal(5, seen.Count);
Assert.Equal(5, seen.Distinct().Count());
Assert.Equal(3, pages);
}
}
@@ -0,0 +1,150 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Monitor.Backend.Tests;
/// <summary>
/// 对照 docs/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
/// 新增端点违反规范时,这里会直接失败(等价于文档里的 <c>eng/check-openapi</c>)。
/// </summary>
[Collection("backend")]
public sealed partial class OpenApiConformanceTests
{
private readonly BackendFixture _backend;
public OpenApiConformanceTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonObject> DocumentAsync() =>
(await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json")).AsObject();
private static IEnumerable<(string Path, string Method, JsonObject Operation)> Operations(JsonObject doc)
{
foreach ((string path, JsonNode? item) in doc["paths"]!.AsObject())
{
foreach ((string method, JsonNode? operation) in item!.AsObject())
{
yield return (path, method, operation!.AsObject());
}
}
}
[Fact]
public async Task EveryOperation_HasAUniqueSnakeCaseOperationId_ASummaryAndATag()
{
JsonObject doc = await DocumentAsync();
var ids = new HashSet<string>();
foreach ((string path, string method, JsonObject operation) in Operations(doc))
{
string id = operation["operationId"]?.GetValue<string>() ?? "";
Assert.True(OperationIdPattern().IsMatch(id), $"{method} {path}: operationId '{id}' must be snake_case (resource_action)");
Assert.True(ids.Add(id), $"duplicate operationId {id}");
Assert.False(string.IsNullOrWhiteSpace(operation["summary"]?.GetValue<string>()), $"{method} {path}: summary");
Assert.NotEmpty(operation["tags"]!.AsArray());
}
}
[Fact]
public async Task Paths_AreLowercaseKebabCase_UnderTheDocumentedPrefixes()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, _) in doc["paths"]!.AsObject())
{
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
string[] roots = ["/healthz", "/metrics", "/install.sh", "/install.ps1"];
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
Assert.True(segment.StartsWith('{') || PathSegmentPattern().IsMatch(segment), $"{path}: '{segment}' must be lowercase kebab-case");
}
}
}
[Fact]
public async Task ErrorResponses_AreProblemJsonWithTheSharedSchema()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc))
{
foreach ((string status, JsonNode? response) in operation["responses"]!.AsObject())
{
if (!status.StartsWith('4') && !status.StartsWith('5'))
{
continue;
}
JsonObject? content = response?["content"]?.AsObject();
if (content is null)
{
continue; // 没有声明正文的框架错误
}
Assert.True(content.ContainsKey("application/problem+json"), $"{method} {path} {status} must use application/problem+json");
Assert.Equal("#/components/schemas/ProblemDto", content["application/problem+json"]!["schema"]!["$ref"]!.GetValue<string>());
}
}
JsonObject problem = doc["components"]!["schemas"]!["ProblemDto"]!.AsObject();
foreach (string required in new[] { "type", "title", "status", "code", "trace_id" })
{
Assert.NotNull(problem["properties"]![required]);
}
}
[Fact]
public async Task SchemaProperties_AreSnakeCase_AndResourcesAreStronglyTyped()
{
JsonObject doc = await DocumentAsync();
foreach ((string name, JsonNode? schema) in doc["components"]!["schemas"]!.AsObject())
{
JsonObject? properties = schema?["properties"]?.AsObject();
if (properties is null || name is "ProblemDetails" or "HttpValidationProblemDetails")
{
continue;
}
foreach ((string property, _) in properties)
{
Assert.True(PropertyPattern().IsMatch(property), $"{name}.{property} must be snake_case");
}
}
// 指标值是字典(metrics: { id: number }),新增指标不需要改 OpenAPI 和前端类型
JsonObject summary = doc["components"]!["schemas"]!["SummaryDto"]!["properties"]!["metrics"]!.AsObject();
Assert.True(summary.ContainsKey("additionalProperties"));
JsonObject series = doc["components"]!["schemas"]!["SeriesDto"]!["properties"]!["values"]!.AsObject();
Assert.True(series.ContainsKey("additionalProperties"));
}
[Fact]
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path != "/api/admin/auth/login"))
{
JsonObject responses = operation["responses"]!.AsObject();
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
}
}
[Fact]
public async Task CreatedResources_DeclareA201_AndAcceptedOnesA202()
{
JsonObject doc = await DocumentAsync();
Assert.True(doc["paths"]!["/api/admin/tasks"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
Assert.True(doc["paths"]!["/api/admin/join-tokens"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
Assert.True(doc["paths"]!["/api/admin/lines/{line_id}/tests"]!["post"]!["responses"]!.AsObject().ContainsKey("202"));
Assert.True(doc["paths"]!["/api/admin/tasks/{id}"]!["delete"]!["responses"]!.AsObject().ContainsKey("204"));
}
[GeneratedRegex("^[a-z0-9]+(_[a-z0-9]+)+$")]
private static partial Regex OperationIdPattern();
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*(\\.[a-z0-9]+)?$")]
private static partial Regex PathSegmentPattern();
[GeneratedRegex("^[a-z0-9_]+$")]
private static partial Regex PropertyPattern();
}
@@ -0,0 +1,296 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Rpc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Monitor.Backend.Analysis;
using Monitor.Backend.Application;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class OperationsAndSecurityTests
{
private const long Minute = 60_000;
private readonly BackendFixture _backend;
public OperationsAndSecurityTests(BackendFixture backend)
{
_backend = backend;
}
private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
private T Service<T>()
where T : class => _backend.Services.GetRequiredService<T>();
private T HostedService<T>()
where T : class => _backend.Services.GetServices<IHostedService>().OfType<T>().Single();
// ───────────────────────── 指标版本与回填 ─────────────────────────
[Fact]
public async Task ANewOrChangedMetric_IsBackfilledOverTheRetainedRawData()
{
long baseMs = ((Now() / Minute) - 4) * Minute;
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000))));
}
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&step=1m&metrics=rtt.p95,loss.total";
JsonNode before = null!;
await BackendFixture.EventuallyAsync(async () =>
{
before = await _backend.GetJsonAsync(_backend.Admin, url);
return before["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200 && before["source"]!.GetValue<string>() == "1m";
});
double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>();
Assert.InRange(rtt, 60 * 0.955, 60 * 1.045);
// 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上
var store = Service<IMetricStore>();
foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default))
{
await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet<string> { "rtt" }, default);
}
await store.SetVersionAsync(new MetricVersionRecord("rtt", "stale-fingerprint", null, null), default);
JsonNode erased = await _backend.GetJsonAsync(_backend.Admin, url);
Assert.Null(erased["summary"]!["metrics"]!["rtt.p95"]);
Assert.NotNull(erased["summary"]!["metrics"]!["loss.total"]); // 其他指标不受影响
// 启动时的指纹比对发现 rtt 变了:在保留期内的原始块上重算它(限速、低优先级)
await HostedService<MetricRecompute>().RunAsync(default);
RecomputeProgress progress = HostedService<MetricRecompute>().Progress;
Assert.Equal("done", progress.State);
Assert.Equal(["rtt"], progress.Metrics.ToArray());
Assert.True(progress.WindowsTotal >= 2);
Assert.Equal(progress.WindowsTotal, progress.WindowsDone);
JsonNode after = await _backend.GetJsonAsync(_backend.Admin, url);
Assert.Equal(rtt, after["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>(), 9);
Assert.Equal(MetricRegistry.Find("rtt")!.Fingerprint, (await store.GetVersionsAsync(default)).Single(v => v.MetricId == "rtt").Fingerprint);
// 完成之后再比对:没有变化,不再重算
await HostedService<MetricRecompute>().RunAsync(default);
Assert.Equal("idle", HostedService<MetricRecompute>().Progress.State);
JsonNode api = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/analysis/recompute");
Assert.Equal("idle", api["state"]!.GetValue<string>());
}
// ───────────────────────── 保留 ─────────────────────────
[Fact]
public async Task Retention_RemovesRawChunksPastTheConfiguredPeriod_AndTheSettingTakesEffectImmediately()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
RawChunk old = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (45L * 24 * 60 * Minute));
RawChunk recent = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (2L * 24 * 60 * Minute));
RawChunk fresh = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (5 * Minute));
foreach (RawChunk chunk in new[] { old, recent, fresh })
{
Assert.True(await agent.SendChunkAsync(chunk));
}
async Task<int> CountAsync() =>
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}"))["items"]!.AsArray().Count;
Assert.Equal(3, await CountAsync());
await HostedService<RetentionService>().RunOnceAsync(default);
Assert.Equal(2, await CountAsync()); // 默认保留 30 天:45 天前的块被删除
using HttpResponseMessage patch = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 1 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
await HostedService<RetentionService>().RunOnceAsync(default);
Assert.Equal(1, await CountAsync());
using HttpResponseMessage restore = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 30 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
}
// ───────────────────────── 节点连接的安全边界 ─────────────────────────
[Fact]
public async Task AnAuthSignatureCannotBeReplayedOnAnotherConnection()
{
await using AgentSimulator agent = await _backend.EnrollOnlyAsync();
byte[]? captured = null;
AuthAttempt first = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => captured = agent.SignAuth(agent.NodeId, nonce));
Assert.True(first.Ok);
AuthAttempt replay = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, _ => captured!);
Assert.False(replay.Ok);
Assert.Equal("fleet_unauthorized", replay.ErrorCode);
}
[Fact]
public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
try
{
await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]);
}
catch (System.Net.WebSockets.WebSocketException)
{
// 服务端可能在发送完成之前就关闭了连接
}
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue<bool>());
// 服务端仍然正常
await using AgentSimulator another = await _backend.ConnectAgentAsync();
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
}
[Fact]
public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.SendTextAsync("{not json");
await agent.SendTextAsync("[]");
await agent.SendBinaryAsync([]);
await agent.SendBinaryAsync([200, 1, 2, 3]); // 未知的二进制通道,长度不够一个完整的头部
await agent.SendBinaryAsync(OutboxFrame.Build(99, Guid.NewGuid(), [1])); // 没有注册的通道
// 节点不能调用任何“执行”类的方法:Fleet 根本没有这样的路由
foreach (string method in new[] { "fleet.exec", "fleet.shell", "monitor.exec", "monitor.write_file", "fleet.update" })
{
RpcMessage reply = await agent.RequestRawAsync(method);
Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code);
}
// 连接仍然可用
ProbeAgentsResult list = await agent.RequestProbeAgentsAsync();
Assert.Contains(list.Agents, a => a.AgentId == agent.NodeId);
}
[Fact]
public async Task WithoutHello_NoStateIsPushed_UntilTheNodeIntroducesItself()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false);
await Task.Delay(400);
Assert.Empty(agent.TaskSets);
await agent.HelloAsync();
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
}
[Fact]
public async Task PendingNodes_CannotCallProductMethods()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
RpcMessage reply = await agent.RequestRawAsync(MonitorMethods.ProbeAgents, JsonSerializer.SerializeToElement(new ProbeAgentsParams(), MonitorJsonContext.Default.ProbeAgentsParams));
Assert.Equal("fleet_node_pending", reply.Error!.Data!.Code);
// 状态与事件也不会被接受:监控数据里没有它
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
await Task.Delay(200);
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
Assert.False(view["probe_connected"]!.GetValue<bool>());
}
[Fact]
public async Task ReconnectingTheSameNode_ReplacesTheOldConnection()
{
AgentSimulator first = await _backend.ConnectAgentAsync();
await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await using AgentSimulator second = await _backend.ReconnectAsync(first);
await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
await first.WaitUntilAsync(() => first.CloseReason is not null);
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + first.NodeId.Value);
Assert.True(view["online"]!.GetValue<bool>());
}
// ───────────────────────── 用户、会话与令牌 ─────────────────────────
[Fact]
public async Task Users_CanBeCreated_PasswordChangeEndsTheirSessions_AndLogoutInvalidatesTheCookie()
{
string name = "user" + Guid.NewGuid().ToString("N")[..8];
using HttpResponseMessage weak = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "short" }));
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
Assert.NotNull(JsonNode.Parse(await weak.Content.ReadAsStringAsync())!["errors"]!["password"]);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
string userId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "another password" }));
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
using HttpClient client = _backend.NewClient();
(string cookie, string csrf) = await LoginAsync(client, name, "first password");
Assert.Equal(HttpStatusCode.OK, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
using HttpResponseMessage change = await _backend.Admin.PatchAsync($"/api/admin/users/{userId}", BackendFixture.Json(new JsonObject { ["password"] = "second password" }));
Assert.Equal(HttpStatusCode.NoContent, change.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); // 旧会话失效
using HttpResponseMessage oldPassword = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
Assert.Equal(HttpStatusCode.Unauthorized, oldPassword.StatusCode);
(cookie, csrf) = await LoginAsync(client, name, "second password");
using HttpResponseMessage logout = await Send(client, HttpMethod.Post, "/api/admin/auth/logout", cookie, csrf);
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
JsonNode users = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/users");
Assert.DoesNotContain("password", users.ToJsonString(), StringComparison.OrdinalIgnoreCase); // 响应里没有任何口令信息
}
[Fact]
public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" }));
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string plaintext = body["plaintext"]!.GetValue<string>();
string id = body["token"]!["id"]!.GetValue<string>();
using HttpClient client = _backend.NewClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", plaintext);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/public/catalog/kinds")).StatusCode);
string listing = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/api-tokens")).ToJsonString();
Assert.DoesNotContain(plaintext, listing, StringComparison.Ordinal);
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/api-tokens/" + id);
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/agents")).StatusCode);
}
private static async Task<(string Cookie, string Csrf)> LoginAsync(HttpClient client, string user, string password)
{
using HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password }));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (response.Headers.GetValues("Set-Cookie").Single().Split(';')[0], JsonNode.Parse(await response.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>());
}
private static Task<HttpResponseMessage> Send(HttpClient client, HttpMethod method, string url, string cookie, string? csrf = null)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Add("Cookie", cookie);
if (csrf is not null)
{
request.Headers.Add("X-CSRF-Token", csrf);
}
return client.SendAsync(request);
}
}
@@ -0,0 +1,68 @@
using System.Text.Json.Nodes;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>重启后端:数据、身份和令牌都必须还在;热层从原始块回填;段文件尾部没有索引的数据被截断。</summary>
public sealed class PersistenceTests : IAsyncLifetime
{
private readonly BackendFixture _backend = new();
public ValueTask InitializeAsync() => _backend.InitializeAsync();
public ValueTask DisposeAsync() => _backend.DisposeAsync();
[Fact]
public async Task EverythingSurvivesARestart_AndTheHotLayerIsRebuiltFromRawChunks()
{
long minute = 60_000;
long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute;
AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000))));
}
string from = DateTimeOffset.FromUnixTimeMilliseconds(baseMs).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string to = DateTimeOffset.FromUnixTimeMilliseconds(baseMs + (2 * minute)).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
string url = $"/api/public/lines/{_backend.Line(id)}/series?from={from}&to={to}&metrics=loss.total,rtt.p95";
JsonNode before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200);
before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m");
await agent.DisposeAsync();
await _backend.RestartAsync();
HttpClient admin = _backend.Admin; // 重启之后用同一个令牌,证明令牌已经落盘
// 身份与令牌:Agent 记录在,管理员令牌仍然有效
JsonNode agents = await _backend.GetJsonAsync(admin, "/api/admin/agents");
JsonNode restored = agents["items"]!.AsArray().Single(a => a!["id"]!.GetValue<string>() == id)!;
Assert.Equal("active", restored["status"]!.GetValue<string>());
Assert.False(restored["online"]!.GetValue<bool>());
// 持久层:重启前算好的分钟窗口原样还在
JsonNode after = await _backend.GetJsonAsync(admin, url + "&step=1m");
Assert.Equal(before["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), after["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 9);
Assert.Equal(25.0, after["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
// 热层:重启后为空,回填之后才会被查询选用;回填完成后 1s 步长能读到同样的数据
JsonNode hot = null!;
await BackendFixture.EventuallyAsync(async () =>
{
hot = await _backend.GetJsonAsync(admin, url + "&step=1s");
return hot["source"]!.GetValue<string>() == "hot" && hot["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200;
});
Assert.Equal(25.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
// 同一个身份可以重新连接,并拿回当前版本的期望状态
await using AgentSimulator again = await _backend.ReconnectAsync(agent);
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
// 已经落盘的块在重启后仍然可以按 ID 下载
JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1");
string chunkId = chunks["items"]![0]!["chunk_id"]!.GetValue<string>();
using HttpResponseMessage download = await admin.GetAsync("/api/admin/raw/chunks/" + chunkId);
Assert.True(RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).IsSuccess);
}
}
@@ -0,0 +1,24 @@
using System.Text.Json;
using Monitor.Backend.Application;
using Monitor.Protocol;
namespace Monitor.Backend.Tests;
public sealed class PlanTests
{
[Fact]
public void Plans_ComposeKnownCollectionPrimitives_AndTheirOverridesAreValid()
{
foreach (PlanSpec plan in Plans.All)
{
foreach (PhaseSpec phase in plan.Phases)
{
KindSpec kind = Kinds.Find(phase.Kind)!;
Assert.NotNull(kind);
Assert.True(ParamResolver.Resolve(kind.Params, phase.Overrides).IsValid, $"{plan.Id}/{phase.Kind}");
}
}
Assert.Null(Plans.Find("nope"));
}
}
@@ -0,0 +1,109 @@
using System.Text.Json.Nodes;
namespace Monitor.Backend.Tests;
/// <summary>真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。</summary>
[Collection("backend")]
public sealed class ProbeEndToEndTests
{
private readonly BackendFixture _backend;
public ProbeEndToEndTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
private async Task<int> ChunkCountAsync(AgentHarness agent, string kind) =>
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == kind);
[Fact]
public async Task Probe_ConnectsThroughTheBootstrapList_RunsTheLineTasks_AndTheBackendStoresMergedChunks()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id)), because: "the probe should connect to the bootstrap agent");
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id)!.RoleCount == 2), because: "udp_echo and tcp_echo");
// 每 10 秒切一块,回显类再等 2 秒宽限
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_echo") >= 1 && await ChunkCountAsync(agent, "tcp_echo") >= 1, TimeSpan.FromSeconds(40));
Assert.True(agent.App.Counters.Fragments >= 2);
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
// 时钟偏移已经估计出来:进程内两端共用同一个单调时钟,所以偏移应当接近 0(真实线路上是两台机器各自的时钟原点之差)
Assert.InRange(probe.App.Connection(agent.Id)!.ClockOffsetUs, -20_000, 20_000);
}
[Fact]
public async Task Probe_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart()
{
await using AgentHarness first = await AgentHarness.StartAsync(_backend);
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(first.Id)));
await using AgentHarness second = await AgentHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new agent should be picked up through agents_changed: " + string.Join(";", probe.App.Directory.Agents.Select(a => $"{a.AgentId}@{a.Host}:{a.TcpPort}")) + " v" + probe.App.Directory.Version + " want " + second.Id);
Assert.Contains(probe.App.Directory.Agents, a => a.AgentId == second.Id);
Assert.True(File.Exists(Path.Combine(probe.Directory, "agents.json")));
// 停用一个 Agent:Probe 断开它
using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject()));
Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync());
await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30));
}
[Fact]
public async Task Hy2Ladder_WithTheRealProbe_Succeeds_AndEveryFragmentIsStored()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } }));
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
JsonNode test = null!;
await BackendFixture.EventuallyAsync(
async () =>
{
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
},
TimeSpan.FromSeconds(90));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments");
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
}
[Fact]
public async Task RealitySession_WithTheRealProbe_RunsTcpBulkAndStoresBytesAndKernelRows()
{
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
JsonNode test = null!;
await BackendFixture.EventuallyAsync(
async () =>
{
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
},
TimeSpan.FromSeconds(150));
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments");
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
}
}
@@ -0,0 +1,68 @@
using System.Text;
using System.Text.Json.Nodes;
using Fleet.Core;
using Monitor.Probe;
namespace Monitor.Backend.Tests;
/// <summary>进程内的真实 Probe:用后端生成的身份文件(和面板下载的 probe.json 同一份)启动 <see cref="ProbeApp"/>。</summary>
public sealed class ProbeHarness : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
private readonly string _directory;
private readonly Task _run;
private ProbeHarness(string directory, ProbeApp app)
{
_directory = directory;
App = app;
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
}
public ProbeApp App { get; }
public string Directory => _directory;
public string ProbeId { get; init; } = "";
/// <summary>向后端申请身份(<c>POST /api/admin/probe/identity</c>),写成 <c>probe.json</c> 并启动。</summary>
public static async Task<ProbeHarness> StartAsync(BackendFixture backend, Action<ProbeFile>? configure = null)
{
JsonNode created = await backend.CreateProbeAsync();
string json = created.ToJsonString();
string directory = Path.Combine(Path.GetTempPath(), "monitor-probe-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "probe.json");
await File.WriteAllTextAsync(path, json, Encoding.UTF8);
ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile);
configure?.Invoke(file);
var app = new ProbeApp(file, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out));
return new ProbeHarness(directory, app) { ProbeId = created["probe_id"]!.GetValue<string>() };
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(15));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 停止中
}
App.Dispose();
_stop.Dispose();
try
{
System.IO.Directory.Delete(_directory, recursive: true);
}
catch (IOException)
{
// 临时目录清理失败不影响测试结果
}
}
}
@@ -0,0 +1,241 @@
using Fleet.Core;
using Microsoft.Extensions.Time.Testing;
using Monitor.Backend.Analysis;
using Monitor.Backend.Application;
using Monitor.Protocol;
namespace Monitor.Backend.Tests;
/// <summary>时间段、步长与数据源的规划:与 docs/query.md 里的表格逐行对应。</summary>
public sealed class QueryPlanningTests
{
private const long Second = 1000;
private const long Minute = 60 * Second;
private const long Hour = 60 * Minute;
private const long Day = 24 * Hour;
private static readonly long s_now = new DateTimeOffset(2026, 10, 3, 12, 34, 56, TimeSpan.Zero).ToUnixTimeMilliseconds();
private readonly BackendOptions _options = new();
private RangeResolver Resolver => new(_options);
private SourcePlanner Planner => new(_options);
private QueryRange Resolve(string range, string? step = null)
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest(range, null, null, step, null), s_now);
Assert.True(result.IsSuccess, result.IsSuccess ? "" : result.Error.Message);
return result.Value;
}
[Theory]
[InlineData("5m", "1s", 300)]
[InlineData("15m", "2s", 450)]
[InlineData("30m", "5s", 360)]
[InlineData("1h", "5s", 720)]
[InlineData("6h", "30s", 720)]
[InlineData("24h", "2m", 720)]
[InlineData("7d", "15m", 672)]
[InlineData("30d", "1h", 720)]
[InlineData("90d", "3h", 720)]
public void AutoStep_MatchesTheDocumentedTable(string range, string step, int points)
{
QueryRange resolved = Resolve(range);
Assert.Equal(step, Steps.Format(resolved.StepMs));
Assert.InRange(resolved.Points - points, 0, 1); // 对齐到步长的整数倍,可能多出一个不完整的桶
}
[Theory]
[InlineData("5m", "hot")]
[InlineData("15m", "hot")]
[InlineData("30m", "hot")]
[InlineData("1h", "hot")]
[InlineData("6h", "10s")]
[InlineData("24h", "1m")]
[InlineData("7d", "5m")]
[InlineData("30d", "1h")]
[InlineData("90d", "1h")]
public void SourceSelection_MatchesTheDocumentedTable(string range, string source)
{
QueryRange resolved = Resolve(range);
SourcePlan plan = Planner.Plan(resolved, s_now, hotReady: true);
Assert.Equal(source, plan.Name);
Assert.False(plan.Degraded);
Assert.Equal(resolved.StepMs, plan.StepMs);
}
[Fact]
public void WhenTheHotLayerIsNotReady_FineStepsOverRecentDataComeFromRawChunks()
{
SourcePlan plan = Planner.Plan(Resolve("5m"), s_now, hotReady: false);
Assert.Equal("raw", plan.Name);
// 区间超过 15 分钟就不现算,降级到 10 s 层
SourcePlan degraded = Planner.Plan(Resolve("1h", step: "5s"), s_now, hotReady: false);
Assert.Equal("10s", degraded.Name);
Assert.True(degraded.Degraded);
Assert.Equal(10 * Second, degraded.StepMs);
}
[Fact]
public void StepsThatNoLayerCanServe_AreDegradedAndSnappedToACommonStep()
{
// 15 s 不能整除 10 s 层的宽度,而且已经超出热层
QueryRange old = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Hour)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (2 * Hour)), "15s", null), s_now).Value;
SourcePlan plan = Planner.Plan(old, s_now, hotReady: true);
Assert.Equal(("10s", true, "30s"), (plan.Name, plan.Degraded, Steps.Format(plan.StepMs)));
Assert.Equal(30 * Second, old.WithStep(plan.StepMs).StepMs);
Assert.Equal(0, old.WithStep(plan.StepMs).FromMs % plan.StepMs);
// 超出 10 s 层的保留期(48 小时),1 分钟步长改用 1 分钟层(不降级:1m 层能整除)
QueryRange week = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day) + Hour), "10s", null), s_now).Value;
SourcePlan expired = Planner.Plan(week, s_now, hotReady: true);
Assert.True(expired.Degraded, "the 10 s layer only keeps 48 hours");
Assert.Equal("1m", expired.Name);
Assert.Equal(Minute, expired.StepMs);
}
[Fact]
public void RangeResolver_AlignsBucketsAndMarksPartialEdges()
{
long from = s_now - (s_now % Minute) + (13 * Second);
long to = from + (4 * Minute) + (7 * Second);
QueryRange range = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), DateTimeOffset.FromUnixTimeMilliseconds(to), "1m", null), s_now).Value;
Assert.Equal(0, range.FromMs % Minute);
Assert.Equal(0, range.ToMs % Minute);
Assert.Equal(5, range.Points);
Assert.True(range.FirstPartial);
Assert.True(range.LastPartial);
Assert.False(range.IsRelative);
QueryRange aligned = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(range.FromMs), DateTimeOffset.FromUnixTimeMilliseconds(range.ToMs), "1m", null), s_now).Value;
Assert.False(aligned.FirstPartial);
Assert.False(aligned.LastPartial);
}
[Theory]
[InlineData("0m")]
[InlineData("-5m")]
[InlineData("5")]
[InlineData("5x")]
[InlineData("m")]
[InlineData("99999999d")]
[InlineData("1000d")]
public void RangeResolver_RejectsMalformedAndAbsurdRanges(string range)
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest(range, null, null, null, null), s_now);
Assert.False(result.IsSuccess);
Assert.Contains(result.Error.Code, new[] { MonitorErrors.InvalidRange, MonitorErrors.RangeTooLarge });
}
[Fact]
public void RangeResolver_EnforcesThePointLimit_AndSuggestsAStep()
{
Result<QueryRange> result = Resolver.Resolve(new RangeRequest("30d", null, null, "1m", null), s_now);
Assert.Equal(MonitorErrors.RangeTooLarge, result.Error.Code);
Assert.Contains("step=", result.Error.Message);
Assert.Equal(MonitorErrors.InvalidStep, Resolver.Resolve(new RangeRequest("1h", null, null, "7s", null), s_now).Error.Code);
Assert.Equal(MonitorErrors.InvalidRange, Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now), DateTimeOffset.FromUnixTimeMilliseconds(s_now), null, null), s_now).Error.Code);
Assert.Equal(MonitorErrors.ValidationFailed, Resolver.Resolve(new RangeRequest("1h", DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Error.Code);
}
[Fact]
public void OnlyUtcIsSupported_OtherTimeZonesAreRejectedInsteadOfSilentlyIgnored()
{
Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, "UTC"), s_now).IsSuccess);
Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, null), s_now).IsSuccess);
Result<QueryRange> shanghai = Resolver.Resolve(new RangeRequest("7d", null, null, "1d", "Asia/Shanghai"), s_now);
Assert.Equal(MonitorErrors.ValidationFailed, shanghai.Error.Code);
Assert.IsType<ValidationError>(shanghai.Error);
}
[Fact]
public void RelativeRanges_AreLive_AbsoluteOnesAreNot()
{
Assert.True(Resolve("15m").IsRelative);
QueryRange fromOnly = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Value;
Assert.True(fromOnly.IsRelative); // 没有 to:to = now,可以跟随实时数据
}
[Fact]
public void Steps_ParseFormatAndSnap()
{
foreach (long step in Steps.Common)
{
Assert.True(Steps.TryParse(Steps.Format(step), out long parsed));
Assert.Equal(step, parsed);
}
Assert.False(Steps.TryParse("3s", out _));
Assert.Equal(30 * Second, Steps.Snap(15 * Second, 10 * Second));
Assert.Equal(5 * Minute, Steps.Snap(Minute, 5 * Minute));
Assert.Equal(-60_000, Steps.FloorTo(-1, Minute));
Assert.Equal(0, Steps.CeilTo(0, Minute));
Assert.Equal(Minute, Steps.CeilTo(1, Minute));
}
[Fact]
public void BucketedRows_MergeExactly_AndDoNotMutateTheirSources()
{
QueryRange range = Resolve("5m", "1m");
var first = new MetricSet();
first.GetOrAdd("c", AccKind.Counter).Add(3);
((HistogramAcc)first.GetOrAdd("h", AccKind.Histogram)).Add(10);
var second = new MetricSet();
second.GetOrAdd("c", AccKind.Counter).Add(4);
((HistogramAcc)second.GetOrAdd("h", AccKind.Histogram)).Add(20);
BucketedRows rows = BucketedRows.Create(
[new SourceRow(range.FromMs, first, false), new SourceRow(range.FromMs + (10 * Second), second, false), new SourceRow(range.FromMs - Minute, first, false), new SourceRow(range.ToMs, first, false)], range);
Assert.Equal(range.Points, rows.T.Length);
Assert.Equal(2, rows.RowCounts[0]);
Assert.Equal(7UL, ((CounterAcc)rows.Sets[0]!.Items["c"]).Value);
Assert.Equal(2UL, ((HistogramAcc)rows.Sets[0]!.Items["h"]).Count);
Assert.Equal(3UL, ((CounterAcc)first.Items["c"]).Value); // 源行没有被改动
Assert.Equal(2, rows.TotalRows); // 区间之外的行被忽略
Assert.Null(rows.Sets[1]);
}
[Fact]
public void MetricSelection_ResolvesPresetsAndScores_AndRejectsUnknownIds()
{
MetricSelection selection = MetricSelection.Resolve("rtt.p95,score.mc", "udp,scores", 100).Value;
Assert.Contains("loss.total", selection.ExportIds);
Assert.Contains("mc", selection.ScoreModelIds);
Assert.Equal(4, selection.ScoreModelIds.Count);
Assert.Equal(selection.ExportIds.Count, selection.ExportIds.Distinct().Count());
Assert.True(selection.WantsScores);
Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("nope", null, 100).Error.Code);
Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("score.nope", null, 100).Error.Code);
Assert.Equal(MonitorErrors.UnknownPreset, MetricSelection.Resolve(null, "nope", 100).Error.Code);
Assert.Equal(MonitorErrors.TooManyMetrics, MetricSelection.Resolve(null, "udp", 3).Error.Code);
}
[Fact]
public void LoginRateLimiter_AllowsABurst_ThenBlocksUntilTheWindowSlides()
{
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
var limiter = new LoginRateLimiter(new BackendOptions { LoginAttemptsPerMinute = 3 }, time);
for (int i = 0; i < 3; i++)
{
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
time.Advance(TimeSpan.FromSeconds(10));
}
Assert.False(limiter.TryAcquire("1.2.3.4", out int retry)); // 已过 30 秒,最早的一次还要 30 秒才滑出窗口
Assert.InRange(retry, 29, 31);
Assert.True(limiter.TryAcquire("5.6.7.8", out _), "another source is independent");
time.Advance(TimeSpan.FromSeconds(31));
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
limiter.Prune();
time.Advance(TimeSpan.FromMinutes(2));
limiter.Prune();
Assert.True(limiter.TryAcquire("1.2.3.4", out _));
}
}
@@ -0,0 +1,103 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
namespace Monitor.Backend.Tests;
public sealed record SseEvent(string Name, JsonNode? Data);
/// <summary>读取 text/event-stream:把事件和心跳注释收集起来,测试按条件等待。</summary>
public sealed class SseClient : IAsyncDisposable
{
private readonly HttpResponseMessage _response;
private readonly CancellationTokenSource _stop = new();
private readonly Task _reader;
private SseClient(HttpResponseMessage response, Stream stream)
{
_response = response;
_reader = Task.Run(() => ReadAsync(stream));
}
public ConcurrentQueue<SseEvent> Events { get; } = new();
private int _comments;
public int Comments => Volatile.Read(ref _comments);
public string? ContentType => _response.Content.Headers.ContentType?.MediaType;
public static async Task<SseClient> ConnectAsync(HttpClient http, string url)
{
HttpResponseMessage response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return new SseClient(response, await response.Content.ReadAsStreamAsync());
}
public async Task<SseEvent> WaitForAsync(Func<SseEvent, bool> predicate, TimeSpan? timeout = null)
{
long started = TimeProvider.System.GetTimestamp();
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15);
while (TimeProvider.System.GetElapsedTime(started) < limit)
{
SseEvent? found = Events.ToArray().FirstOrDefault(predicate);
if (found is not null)
{
return found;
}
await Task.Delay(25);
}
throw new TimeoutException("the expected server-sent event never arrived; got: " + string.Join(", ", Events.Select(e => e.Name)));
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
_response.Dispose();
try
{
await _reader;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException)
{
// 连接被关闭
}
_stop.Dispose();
}
private async Task ReadAsync(Stream stream)
{
using var reader = new StreamReader(stream);
string? name = null;
var data = new System.Text.StringBuilder();
while (!_stop.IsCancellationRequested)
{
string? line = await reader.ReadLineAsync(_stop.Token);
if (line is null)
{
return;
}
if (line.StartsWith(':'))
{
Interlocked.Increment(ref _comments);
}
else if (line.StartsWith("event: ", StringComparison.Ordinal))
{
name = line["event: ".Length..];
}
else if (line.StartsWith("data: ", StringComparison.Ordinal))
{
data.Append(line["data: ".Length..]);
}
else if (line.Length == 0 && name is not null)
{
Events.Enqueue(new SseEvent(name, JsonNode.Parse(data.ToString())));
name = null;
data.Clear();
}
}
}
}
@@ -0,0 +1,398 @@
using System.Text;
using Fleet.Core;
using Fleet.Server;
using Microsoft.Data.Sqlite;
using Monitor.Backend.Analysis;
using Monitor.Backend.Application;
using Monitor.Backend.Storage.Sqlite;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>存储层:段文件与索引、指标窗口、队列、节点与令牌。直接使用真实的 SQLite 和临时目录。</summary>
public sealed class StorageTests : IDisposable
{
private static readonly NodeId s_agent = new("agt_storage");
private readonly string _dir = Path.Combine(Path.GetTempPath(), "monitor-storage-" + Guid.NewGuid().ToString("N"));
private readonly SqliteDatabase _db;
public StorageTests()
{
_db = new SqliteDatabase(_dir);
}
public void Dispose()
{
_db.Dispose();
try
{
Directory.Delete(_dir, recursive: true);
}
catch (IOException)
{
// 临时目录清理失败不影响测试结果
}
}
private static RawChunkAppend Chunk(NodeId agent, string kind, long start, string day, Guid? id = null, TestId? test = null, int? phase = null, string series = "line", byte[]? bytes = null)
{
var meta = new RawChunkMeta(new ChunkId(id ?? Guid.CreateVersion7()), agent, series, kind, null, test, phase, start, 10_000, false, "", 0, 0);
return new RawChunkAppend(meta, bytes ?? Encoding.UTF8.GetBytes("chunk-" + Guid.NewGuid()), day);
}
// ───────────────────────── 段文件 ─────────────────────────
[Fact]
public async Task Raw_AppendReadAndDedupe()
{
var raw = new SqliteRawStore(_db);
RawChunkAppend a = Chunk(s_agent, "udp_echo", 1_000, "2026-01-01");
RawChunkAppend b = Chunk(s_agent, "udp_echo", 11_000, "2026-01-01");
Assert.True(await raw.AppendAsync(a, default));
Assert.True(await raw.AppendAsync(b, default));
Assert.False(await raw.AppendAsync(a, default), "same chunk_id twice is a duplicate");
IReadOnlyList<RawChunkMeta> metas = await raw.FindAsync(new RawQuery(s_agent), default);
Assert.Equal(2, metas.Count);
Assert.Equal("2026-01-01/agt_storage/udp_echo.seg", metas[0].SegmentPath);
Assert.Equal(0, metas[0].Offset);
Assert.Equal(metas[0].Length, metas[1].Offset); // 同一个段文件里依次追加
Assert.Equal(a.Bytes.ToArray(), await raw.ReadAsync(metas[0], default));
Assert.Equal(b.Bytes.ToArray(), await raw.ReadAsync(metas[1], default));
Assert.Equal((await raw.GetMetaAsync(a.Meta.ChunkId, default))!.Offset, metas[0].Offset);
Assert.Equal(a.Bytes.Length + b.Bytes.Length, raw.TotalBytes);
}
[Fact]
public async Task Raw_PathsAreSanitised_SoIdsCannotEscapeTheRawDirectory()
{
var raw = new SqliteRawStore(_db);
var evil = new NodeId("../../../outside");
Assert.True(await raw.AppendAsync(Chunk(evil, "../etc", 1, "2026-01-01"), default));
Assert.True(await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-01-01", test: new TestId("..\\..\\x"), phase: 0), default));
foreach (string file in Directory.EnumerateFiles(_dir, "*.seg", SearchOption.AllDirectories))
{
Assert.StartsWith(Path.GetFullPath(Path.Combine(_dir, "raw")), Path.GetFullPath(file));
}
Assert.False(File.Exists(Path.Combine(_dir, "outside")));
await Assert.ThrowsAsync<ArgumentException>(async () => await raw.AppendAsync(Chunk(s_agent, "udp_echo", 3, "../2026"), default));
}
[Fact]
public async Task Raw_FindFiltersAndPagesByStartTime()
{
var raw = new SqliteRawStore(_db);
var testId = new TestId("tst_one");
for (int i = 0; i < 6; i++)
{
await raw.AppendAsync(Chunk(s_agent, "udp_echo", i * 10_000L, "2026-01-01"), default);
}
await raw.AppendAsync(Chunk(s_agent, "tcp_echo", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(new NodeId("agt_other"), "udp_echo", 5_000, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 0, "2026-01-01", test: testId, phase: 1), default);
Assert.Equal(8, (await raw.FindAsync(new RawQuery(s_agent), default)).Count);
Assert.Equal(6, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo"), default)).Count);
Assert.Single(await raw.FindAsync(new RawQuery(TestId: testId), default));
Assert.Equal(3, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", FromMs: 10_000, ToMs: 40_000), default)).Count);
Assert.Equal(2, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", Limit: 2), default)).Count);
Assert.Equal(4, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", AfterStartTs: 20_000), default)).Count);
IReadOnlyList<(NodeId AgentId, string Series)> series = await raw.ListSeriesAsync(0, 100_000, default);
Assert.Equal(2, series.Count); // 测试的块不算持续序列
}
[Fact]
public async Task Raw_RecoverTruncatesAnUnindexedTail_AndLaterAppendsStayConsistent()
{
var raw = new SqliteRawStore(_db);
RawChunkAppend first = Chunk(s_agent, "udp_echo", 1, "2026-01-01");
await raw.AppendAsync(first, default);
string segment = Directory.EnumerateFiles(_dir, "udp_echo.seg", SearchOption.AllDirectories).Single();
long indexedEnd = new FileInfo(segment).Length;
// 追加之后、写索引之前崩溃:段文件尾部多了没有索引记录的字节
await File.AppendAllTextAsync(segment, "ORPHANED-TAIL-FROM-A-CRASH");
Assert.True(new FileInfo(segment).Length > indexedEnd);
await raw.RecoverAsync(default);
Assert.Equal(indexedEnd, new FileInfo(segment).Length);
RawChunkAppend second = Chunk(s_agent, "udp_echo", 2, "2026-01-01");
await raw.AppendAsync(second, default);
IReadOnlyList<RawChunkMeta> metas = await raw.FindAsync(new RawQuery(s_agent), default);
Assert.Equal(first.Bytes.ToArray(), await raw.ReadAsync(metas[0], default));
Assert.Equal(second.Bytes.ToArray(), await raw.ReadAsync(metas[1], default));
Assert.Equal(indexedEnd, metas[1].Offset);
}
[Fact]
public async Task Raw_DeleteExpired_UsesSeparateRetentionForTests_AndRemovesEmptyDirectories()
{
var raw = new SqliteRawStore(_db);
var testId = new TestId("tst_old");
await raw.AppendAsync(Chunk(s_agent, "udp_echo", 1, "2026-01-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-03-01"), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 3, "2026-01-01", test: testId, phase: 0), default);
await raw.AppendAsync(Chunk(s_agent, "udp_train", 4, "2026-02-01", test: new TestId("tst_mid"), phase: 0), default);
// 持续任务保留到 2026-02-01 之后,测试保留到 2026-01-15 之后
int removed = await raw.DeleteExpiredAsync(new DateOnly(2026, 2, 1), new DateOnly(2026, 1, 15), default);
Assert.Equal(2, removed);
IReadOnlyList<RawChunkMeta> left = await raw.FindAsync(new RawQuery(), default);
Assert.Equal(2, left.Count);
Assert.Contains(left, m => m.StartTs == 2);
Assert.Contains(left, m => m.TestId?.Value == "tst_mid");
Assert.False(Directory.Exists(Path.Combine(_dir, "raw", "2026-01-01")), "empty day directories are removed");
}
// ───────────────────────── 指标窗口 ─────────────────────────
private static MetricRow Row(long window, Action<MetricSet> fill, bool partial = false)
{
var set = new MetricSet();
fill(set);
return new MetricRow(s_agent, "line", window, partial, AccumulatorCodec.Encode(set));
}
private static ulong Count(MetricRow row, string id) => ((CounterAcc)AccumulatorCodec.Decode(row.Metrics).Value.Items[id]).Value;
[Fact]
public async Task Metrics_MergeAdds_UpsertReplaces_PatchOnlyReplacesNamedMetrics()
{
var store = new SqliteMetricStore(_db);
await store.MergeAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(3))], default);
await store.MergeAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(4)), Row(10_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1))], default);
IReadOnlyList<MetricRow> rows = await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 20_000, default);
Assert.Equal([7UL, 1UL], rows.Select(r => Count(r, "a")).ToArray());
await store.UpsertAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(100))], default);
Assert.Equal(100UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0], "a"));
// 回填一个新指标:只替换 b,a 保持不变
await store.PatchAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("b", AccKind.Counter).Add(5))], new HashSet<string> { "b" }, default);
MetricRow patched = (await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0];
Assert.Equal(100UL, Count(patched, "a"));
Assert.Equal(5UL, Count(patched, "b"));
await store.PatchAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("b", AccKind.Counter).Add(6))], new HashSet<string> { "b" }, default);
Assert.Equal(6UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0], "b"));
// 对不存在的行打补丁会创建它(历史回填)
await store.PatchAsync(MetricLevel.S10, [Row(50_000, s => s.GetOrAdd("b", AccKind.Counter).Add(9))], new HashSet<string> { "b" }, default);
Assert.Equal(9UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 50_000, 60_000, default))[0], "b"));
}
[Fact]
public async Task Metrics_MergeKeepsThePartialFlag_AndReadsHonourHalfOpenRangesAndAgents()
{
var store = new SqliteMetricStore(_db);
await store.MergeAsync(MetricLevel.M1, [Row(60_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1), partial: true)], default);
await store.MergeAsync(MetricLevel.M1, [Row(60_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1), partial: false)], default);
Assert.True((await store.ReadAsync(MetricLevel.M1, s_agent, "line", 0, 120_000, default))[0].Partial);
var other = new MetricRow(new NodeId("agt_other"), "line", 60_000, false, AccumulatorCodec.Encode(new MetricSet()));
await store.UpsertAsync(MetricLevel.M1, [other, Row(120_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1))], default);
Assert.Single(await store.ReadAsync(MetricLevel.M1, s_agent, "line", 60_000, 120_000, default)); // [from, to)
Assert.Equal(3, (await store.ReadAgentsAsync(MetricLevel.M1, [s_agent, new NodeId("agt_other")], "line", 0, 999_999, default)).Count);
Assert.Empty(await store.ReadAgentsAsync(MetricLevel.M1, [], "line", 0, 999_999, default));
Assert.Equal(2, await store.DeleteOlderThanAsync(MetricLevel.M1, 100_000, default)); // 60_000 的两行(两个 Agent)被删,120_000 保留
Assert.Single(await store.ReadAsync(MetricLevel.M1, s_agent, "line", 0, 999_999, default));
}
[Fact]
public async Task Queue_SequenceNumbersProtectLateChunksQueuedWhileAWindowWasBeingComputed()
{
var store = new SqliteMetricStore(_db);
var item = new QueueItem(s_agent, "line", 60_000, NotBefore: 1_000, Reason: "new");
await store.EnqueueAsync([item], default);
IReadOnlyList<QueueItem> first = await store.DequeueDueAsync(2_000, 10, default);
Assert.Single(first);
// 处理期间又来了一个迟到的块:同一个窗口再次入队
await store.EnqueueAsync([item with { NotBefore = 5_000, Reason = "late_chunk" }], default);
await store.CompleteAsync(first, default); // 完成的是旧序号
Assert.Equal(1, await store.QueueLengthAsync(default));
IReadOnlyList<QueueItem> again = await store.DequeueDueAsync(6_000, 10, default);
Assert.Single(again);
Assert.True(again[0].Seq > first[0].Seq);
Assert.Equal(1_000, again[0].NotBefore); // 取较早的就绪时间
await store.CompleteAsync(again, default);
Assert.Equal(0, await store.QueueLengthAsync(default));
Assert.Empty(await store.DequeueDueAsync(0, 10, default));
}
[Fact]
public async Task RollupDirty_IsDeduplicated_AndLowerLevelsComeFirst()
{
var store = new SqliteMetricStore(_db);
await store.MarkRollupDirtyAsync(MetricLevel.H1, s_agent, "line", 0, default);
await store.MarkRollupDirtyAsync(MetricLevel.M5, s_agent, "line", 300_000, default);
await store.MarkRollupDirtyAsync(MetricLevel.M5, s_agent, "line", 300_000, default);
var taken = (await store.TakeRollupDirtyAsync(10, default)).ToList();
Assert.Equal([MetricLevel.M5, MetricLevel.H1], taken.Select(t => t.Level).ToArray());
Assert.Empty(await store.TakeRollupDirtyAsync(10, default));
}
[Fact]
public async Task MetricVersions_AreStoredPerMetric()
{
var store = new SqliteMetricStore(_db);
await store.SetVersionAsync(new MetricVersionRecord("rtt", "abc", null, null), default);
await store.SetVersionAsync(new MetricVersionRecord("rtt", "def", 1, 2), default);
MetricVersionRecord version = Assert.Single(await store.GetVersionsAsync(default));
Assert.Equal(("def", 1L, 2L), (version.Fingerprint, version.RecomputeFrom, version.RecomputeCursor));
}
// ───────────────────────── 节点、令牌、设置、事件 ─────────────────────────
private static NewNode Node(string id, string name, NodeStatus status = NodeStatus.Active, byte fill = 1) =>
new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000);
[Fact]
public async Task Agents_NamesAreUniqueAmongEstablishedNodes_PendingOnesMayRepeat()
{
var store = new SqliteAgentStore(_db);
Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_1", "hk"), default)).IsSuccess);
Result<NodeRecord> clash = await ((IFleetStore)store).CreateNodeAsync(Node("agt_2", "hk"), default);
Assert.Equal(FleetErrors.NameConflict, clash.Error.Code);
Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_3", "hk", NodeStatus.Pending), default)).IsSuccess);
Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_4", "hk", NodeStatus.Pending), default)).IsSuccess);
Result<AgentRecord> approve = await store.SetStatusAsync(new NodeId("agt_3"), NodeStatus.Active, default);
Assert.Equal(MonitorErrors.NameConflict, approve.Error.Code);
// 接管:保留旧 ID 与历史,换成新公钥,删除待审批记录
Result<AgentRecord> takeover = await store.TakeoverAsync(new NodeId("agt_3"), default);
Assert.Equal("agt_1", takeover.Value.Id.Value);
Assert.Equal(new byte[] { 1, 2, 3 }, takeover.Value.PublicKey);
Assert.Null(await store.GetAsync(new NodeId("agt_3"), default));
Assert.Equal(NodeStatus.Pending, (await store.GetAsync(new NodeId("agt_4"), default))!.Status);
Assert.Equal(MonitorErrors.AgentNotFound, (await store.TakeoverAsync(new NodeId("agt_1"), default)).Error.Code); // 只有 pending 能接管别人
}
[Fact]
public async Task Agents_PatchUsesRowVersionForOptimisticConcurrency()
{
var store = new SqliteAgentStore(_db);
await ((IFleetStore)store).CreateNodeAsync(Node("agt_1", "hk"), default);
AgentRecord current = (await store.GetAsync(new NodeId("agt_1"), default))!;
Result<AgentRecord> stale = await store.PatchAsync(current.Id, new AgentPatch { Labels = ["x"] }, current.RowVersion + 5, default);
Assert.Equal(MonitorErrors.PreconditionFailed, stale.Error.Code);
Result<AgentRecord> ok = await store.PatchAsync(current.Id, new AgentPatch { Labels = ["x"] }, current.RowVersion, default);
Assert.Equal(current.RowVersion + 1, ok.Value.RowVersion);
Assert.Equal(["x"], ok.Value.Labels);
Assert.Equal("hk", ok.Value.Name); // 没写的字段不动
Assert.Equal(MonitorErrors.AgentNotFound, (await store.PatchAsync(new NodeId("agt_x"), new AgentPatch(), null, default)).Error.Code);
}
[Fact]
public async Task JoinTokens_AreConsumedAtomically_EvenUnderConcurrency()
{
var tokens = new SqliteJoinTokenStore(_db);
var fleet = new SqliteAgentStore(_db);
byte[] hash = TokenHash.Sha256("jt_race");
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default);
Assert.Equal(0, created.Uses);
bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default))));
Assert.Equal(3, results.Count(r => r));
Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default));
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, 5, expiresAtMs: 5_000, nowMs: 1_000, default);
Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_2", 3_000, default), "revoked");
}
[Fact]
public async Task Settings_IncrementIsAtomic_AndEventsPage()
{
var settings = new SqliteSettingsStore(_db);
long[] values = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => Task.Run(async () => await settings.IncrementAsync("counter", default))));
Assert.Equal(Enumerable.Range(1, 50).Select(i => (long)i), values.Order());
Assert.Equal("50", await settings.GetAsync("counter", default));
var events = new SqliteEventStore(_db);
for (int i = 0; i < 5; i++)
{
await events.AddAsync(s_agent, i % 2 == 0 ? "a" : "b", 1_000 + i, "d" + i, default);
}
IReadOnlyList<EventRecord> all = await events.ListAsync(s_agent, null, 0, 10_000, null, 10, default);
Assert.Equal(5, all.Count);
Assert.True(all[0].Id > all[1].Id); // 新的在前
IReadOnlyList<EventRecord> page2 = await events.ListAsync(s_agent, null, 0, 10_000, all[1].Id, 10, default);
Assert.Equal(3, page2.Count);
Assert.Equal(3, (await events.ListAsync(s_agent, "a", 0, 10_000, null, 10, default)).Count);
Assert.Equal(2, await events.DeleteOlderThanAsync(1_002, default));
}
// ───────────────────────── 写入者与模式 ─────────────────────────
[Fact]
public async Task Writer_RollsBackAFailedTransaction_AndKeepsServingLaterWrites()
{
var settings = new SqliteSettingsStore(_db);
await Assert.ThrowsAsync<SqliteException>(async () => await _db.WriteAsync(c =>
{
c.Exec("INSERT INTO settings (key, value) VALUES ('half', 'written')");
c.Exec("INSERT INTO settings (key, value) VALUES ('half', 'duplicate-primary-key')");
}, default));
Assert.Null(await settings.GetAsync("half", default)); // 第一条插入也被回滚
await settings.SetAsync("after", "still works", default);
Assert.Equal("still works", await settings.GetAsync("after", default));
}
[Fact]
public async Task Writes_AreSerialised_NoLostUpdatesUnderParallelLoad()
{
var settings = new SqliteSettingsStore(_db);
await Task.WhenAll(Enumerable.Range(0, 200).Select(i => Task.Run(async () => await settings.SetAsync("k" + i, i.ToString(System.Globalization.CultureInfo.InvariantCulture), default))));
Assert.Equal(200, (await settings.GetAllAsync(default)).Count);
}
[Fact]
public void Schema_RefusesADatabaseFromANewerBuild()
{
string dir = Path.Combine(Path.GetTempPath(), "monitor-schema-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
using (var connection = new SqliteConnection($"Data Source={Path.Combine(dir, "monitor.db")}"))
{
connection.Open();
connection.Exec("PRAGMA user_version = 99");
}
SqliteConnection.ClearAllPools();
Assert.Throws<InvalidOperationException>(() => new SqliteDatabase(dir));
}
finally
{
SqliteConnection.ClearAllPools();
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Health_ReportsBothDatabaseAndRawDirectory()
{
StorageHealth health = await new SqliteStorageHealth(_db).CheckAsync(default);
Assert.True(health.Database);
Assert.True(health.RawDirectory);
}
}