refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
This commit is contained in:
1 parent
17b35223aa
commit
83e5120377
309 files changed
+1505
-1499
No files matched your search
@@ -0,0 +1,332 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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>());
|
||||
|
||||
HttpResponseMessage? accepted = null;
|
||||
string? startFailure = null;
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
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, ["duration_s"] = 5 } }));
|
||||
if (accepted.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
startFailure = await accepted.Content.ReadAsStringAsync();
|
||||
string? code = JsonNode.Parse(startFailure)?["code"]?.GetValue<string>();
|
||||
accepted.Dispose();
|
||||
accepted = null;
|
||||
if (code != "probe_offline" || attempt == 4)
|
||||
{
|
||||
Assert.Fail($"The test request was rejected: {startFailure}");
|
||||
}
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>(), TimeSpan.FromSeconds(15), "the probe should reconnect after a transient disconnect");
|
||||
}
|
||||
|
||||
HttpResponseMessage acceptedResponse = accepted ?? throw new InvalidOperationException("The test request was not accepted.");
|
||||
using (acceptedResponse)
|
||||
{
|
||||
string testId = JsonNode.Parse(await acceptedResponse.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 阶段(5 s、1000 B 的包)里 100 Mbit/s 的那一段有 6 万多条记录,被 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 MoShiro.Agent;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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(), "moshiro-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, directory, 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,130 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 对一个正在运行的 NativeAOT 发布产物跑一遍真实的 Agent 流程(注册、WebSocket、状态同步、二进制收件箱、查询、SSE、按需测试)。
|
||||
/// 只有设置了 <c>MOSHIRO_AOT_URL</c> 才会运行:
|
||||
/// <c>dotnet publish backend/src/MoShiro.Backend -r win-x64 -p:PublishAot=true</c>,启动产物,再设置该环境变量后运行本测试。
|
||||
/// 管理员凭据与测试夹具相同(用户 admin,口令见 <see cref="BackendFixture.AdminPassword"/>)。
|
||||
/// </summary>
|
||||
public sealed class AotSmokeTests
|
||||
{
|
||||
private static string? Url => Environment.GetEnvironmentVariable("MOSHIRO_AOT_URL");
|
||||
|
||||
[Fact]
|
||||
public async Task TheNativeBinary_ServesTheWholeAgentWorkflow()
|
||||
{
|
||||
Assert.SkipUnless(Url is not null, "set MOSHIRO_AOT_URL to the address of a running NativeAOT build to run this test");
|
||||
var baseAddress = new Uri(Url!);
|
||||
using var http = new HttpClient { BaseAddress = baseAddress };
|
||||
|
||||
// 管理员:登录 → 令牌
|
||||
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);
|
||||
|
||||
using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json"));
|
||||
settingsResponse.EnsureSuccessStatusCode();
|
||||
using HttpResponseMessage probeResponse = await http.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "aot-probe-" + Guid.NewGuid().ToString("N")[..6] }));
|
||||
probeResponse.EnsureSuccessStatusCode();
|
||||
string probeId = JsonNode.Parse(await probeResponse.Content.ReadAsStringAsync())!["probe_id"]!.GetValue<string>();
|
||||
|
||||
// Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态
|
||||
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
|
||||
joinResponse.EnsureSuccessStatusCode();
|
||||
string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var anonymous = new HttpClient { BaseAddress = baseAddress };
|
||||
await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]);
|
||||
agent.ProbeForChunks = new ProbeId(probeId);
|
||||
string lineId = $"{agent.NodeId.Value}~{probeId}";
|
||||
await agent.ConnectAsync(baseAddress);
|
||||
TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2);
|
||||
Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo");
|
||||
|
||||
// 原始块:二进制通道 → 段文件与索引 → ACK
|
||||
long minute = 60_000;
|
||||
long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute;
|
||||
for (int k = 0; k < 6; k++)
|
||||
{
|
||||
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000))));
|
||||
}
|
||||
|
||||
// 查询:热层的精确汇总,随后是持久层的分钟窗口
|
||||
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + minute)}&metrics=loss.total,rtt.p95";
|
||||
JsonNode hot = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1s");
|
||||
Assert.Equal("hot", hot["source"]!.GetValue<string>());
|
||||
Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
|
||||
Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
|
||||
long deadline = TimeProvider.System.GetTimestamp();
|
||||
JsonNode persisted;
|
||||
do
|
||||
{
|
||||
persisted = await GetAsync(http, $"/api/public/lines/{lineId}/series?{range}&step=1m");
|
||||
if (persisted["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
while (TimeProvider.System.GetElapsedTime(deadline) < TimeSpan.FromSeconds(15));
|
||||
Assert.Equal("1m", persisted["source"]!.GetValue<string>());
|
||||
Assert.Equal(600, persisted["summary"]!["samples"]!["udp_echo"]!.GetValue<long>());
|
||||
|
||||
// 原始块可以按 ID 下载并解码
|
||||
JsonNode chunks = await GetAsync(http, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=1");
|
||||
byte[] bytes = await http.GetByteArrayAsync("/api/admin/raw/chunks/" + chunks["items"]![0]!["chunk_id"]!.GetValue<string>());
|
||||
Assert.True(RawChunkCodec.Decode(bytes).IsSuccess);
|
||||
|
||||
// SSE:快照 + 增量
|
||||
await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{lineId}:1s:udp");
|
||||
await sse.WaitForAsync(e => e.Name == "series");
|
||||
|
||||
// 按需测试:Probe 事件 → 下发 → 状态
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, probeId);
|
||||
JsonNode started = null!;
|
||||
long begun = TimeProvider.System.GetTimestamp();
|
||||
while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
using HttpResponseMessage response = await http.PostAsync($"/api/admin/lines/{lineId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
started = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
Assert.NotNull(started);
|
||||
await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty);
|
||||
await agent.SendTestStatusAsync(new TestId(started["id"]!.GetValue<string>()), TestStatuses.Running, 0);
|
||||
await sse.WaitForAsync(e => e.Name == "node");
|
||||
|
||||
// 安全:匿名不能访问管理接口
|
||||
using HttpResponseMessage denied = await anonymous.GetAsync("/api/admin/agents");
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||
}
|
||||
|
||||
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
|
||||
private static async Task<JsonNode> GetAsync(HttpClient http, string url)
|
||||
{
|
||||
using HttpResponseMessage response = await http.GetAsync(url);
|
||||
string text = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}");
|
||||
return JsonNode.Parse(text)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.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 Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
||||
{
|
||||
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, ["return_session"] = true }));
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
Assert.False(login.Headers.Contains("Set-Cookie"));
|
||||
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
||||
Assert.False(string.IsNullOrEmpty(session));
|
||||
|
||||
// 之后当 API 令牌那样用 Bearer;不是 Cookie,所以不需要 CSRF,也能做非 GET 操作
|
||||
using var me = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||
|
||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
|
||||
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage created = await client.SendAsync(create);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
// 退出之后这个会话令牌就失效
|
||||
using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/admin/auth/logout");
|
||||
logout.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage loggedOut = await client.SendAsync(logout);
|
||||
Assert.Equal(HttpStatusCode.NoContent, loggedOut.StatusCode);
|
||||
using var after = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me");
|
||||
after.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||
using HttpResponseMessage afterResponse = await client.SendAsync(after);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, afterResponse.StatusCode);
|
||||
}
|
||||
|
||||
[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("moshiro_agents_online", text);
|
||||
Assert.Contains("moshiro_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>;
|
||||
|
||||
[CollectionDefinition("probe-e2e")]
|
||||
public sealed class ProbeEndToEndCollection : ICollectionFixture<BackendFixture>;
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace MoShiro.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;
|
||||
// backend/ 目录的标志:BannedSymbols.txt 只在这里(global.json 和 NuGet 版本表已经挪到仓库根目录)
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "BannedSymbols.txt")))
|
||||
{
|
||||
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(NormalizePath((string?)e.Attribute("Include") ?? "")))],
|
||||
[.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]);
|
||||
})];
|
||||
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
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_NeverReferencesMoShiro_InProjectsOrCode()
|
||||
{
|
||||
foreach (Project project in LoadProjects().Where(IsFleet))
|
||||
{
|
||||
Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("MoShiro.", 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 MoShiro.", StringComparison.Ordinal) || text.Contains("namespace MoShiro.", StringComparison.Ordinal), $"{file} refers to MoShiro.*");
|
||||
Assert.False(text.Contains("moshiro.", StringComparison.Ordinal) && text.Contains("\"moshiro.", StringComparison.Ordinal), $"{file} hard-codes a MoShiro method or channel name");
|
||||
Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a MoShiro port");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList()
|
||||
{
|
||||
string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "MoShiro.Protocol", "MoShiro.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 == "MoShiro.Protocol").ProjectReferences.Order().ToArray());
|
||||
Assert.Equal(["MoShiro.Protocol"], projects.Single(p => p.Name == "MoShiro.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("MoShiro.Agent");
|
||||
Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("MoShiro.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal));
|
||||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], agent.ProjectReferences.Order().ToArray());
|
||||
|
||||
// Probe 同理,并且不认识 Backend 和 Agent
|
||||
Project probe = Get("MoShiro.Probe");
|
||||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], probe.ProjectReferences.Order().ToArray());
|
||||
|
||||
// 两个节点的共享库只依赖协议
|
||||
Assert.Equal(["MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.ToArray());
|
||||
|
||||
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
|
||||
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent", "MoShiro.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/MoShiro.Protocol", "shared/MoShiro.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 == "MoShiro.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("MoShiro.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||||
Assert.Equal(["MoShiro.Backend.Application"], Get("MoShiro.Backend.Api").ProjectReferences.ToArray());
|
||||
|
||||
// Application 不依赖任何下游层
|
||||
Assert.DoesNotContain(Get("MoShiro.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("MoShiro.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal));
|
||||
|
||||
// Storage 实现 Application 的端口,不认识 Api 和 Gateway
|
||||
Assert.DoesNotContain(Get("MoShiro.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||||
|
||||
// 只有宿主(组合根)认识所有层
|
||||
Assert.Equivalent(
|
||||
new[] { "MoShiro.Backend.Api", "MoShiro.Backend.Gateway", "MoShiro.Backend.Storage.Sqlite" },
|
||||
Get("MoShiro.Backend").ProjectReferences.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackendPackages_AreLimitedToTheDocumentedSet()
|
||||
{
|
||||
string[] allowed =
|
||||
[
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "SQLitePCLRaw.lib.e_sqlite3", "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[] { "MoShiro.Backend.Application", "MoShiro.Backend.Api", "MoShiro.Backend.Gateway", "MoShiro.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", "MoShiro.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,231 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <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(), "moshiro-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 MoShiro.Protocol.ProbeId(DefaultProbeId);
|
||||
_agents.Add(agent);
|
||||
await agent.ConnectAsync(BaseAddress);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private void CreateClients()
|
||||
{
|
||||
Anonymous = NewClient();
|
||||
Admin = NewClient();
|
||||
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||
}
|
||||
|
||||
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
|
||||
|
||||
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
|
||||
|
||||
public async Task<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 MoShiro.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;
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
});
|
||||
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,379 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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")));
|
||||
|
||||
// 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true");
|
||||
Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode);
|
||||
tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
|
||||
}
|
||||
|
||||
[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(MoShiroChannels.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.True(Uri.TryCreate(identity["server"]!.GetValue<string>(), UriKind.Absolute, out _)); // Probe 直接连面板:probe.json 里是面板的地址
|
||||
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);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> PostEnrollAsync(JsonNode body) =>
|
||||
_backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body));
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Agent;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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"));
|
||||
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n"));
|
||||
}
|
||||
|
||||
[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), procRoot: "/proc", sysRoot: "/sys");
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_UsesMountedHostProcAndSys_InsteadOfContainerPaths()
|
||||
{
|
||||
var reads = new List<string>();
|
||||
var sampler = new HostSampler(TimeProvider.System, path =>
|
||||
{
|
||||
reads.Add(path);
|
||||
return path switch
|
||||
{
|
||||
"/host/proc/stat" => Stat1,
|
||||
"/host/proc/meminfo" => "MemTotal: 8192 kB\nMemAvailable: 6144 kB\n",
|
||||
"/host/proc/net/dev" => Dev1,
|
||||
"/host/proc/uptime" => "3600.0 7000.0",
|
||||
"/host/sys/class/net/eth0/speed" => "10000\n",
|
||||
_ => throw new InvalidOperationException("unexpected container path: " + path),
|
||||
};
|
||||
}, () => (1000, 250), procRoot: "/host/proc", sysRoot: "/host/sys");
|
||||
|
||||
HostSnapshot snapshot = sampler.Sample();
|
||||
Assert.Equal(8L * 1024 * 1024, snapshot.MemTotal);
|
||||
Assert.Equal(1000, snapshot.NetRxTotal);
|
||||
Assert.Equal(10000, snapshot.NetLinkMbps);
|
||||
Assert.Equal(3600, snapshot.UptimeSeconds);
|
||||
Assert.DoesNotContain(reads, path => path.StartsWith("/proc/", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(reads, path => path.Contains("docker0", StringComparison.Ordinal) || path.Contains("/lo/", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("-1")]
|
||||
[InlineData("0")]
|
||||
[InlineData("not supported")]
|
||||
public void Sample_DoesNotInventLinkSpeedOrContainerDisk_WhenHostDataIsUnavailable(string? speed)
|
||||
{
|
||||
var sampler = new HostSampler(TimeProvider.System,
|
||||
path => path.EndsWith("net/dev", StringComparison.Ordinal) ? Dev1 : path.EndsWith("/speed", StringComparison.Ordinal) ? speed : null,
|
||||
procRoot: "/host/proc", sysRoot: "/host/sys", diskRoot: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
|
||||
|
||||
HostSnapshot snapshot = sampler.Sample();
|
||||
Assert.Null(snapshot.NetLinkMbps);
|
||||
Assert.Null(snapshot.DiskTotal);
|
||||
Assert.Null(snapshot.DiskUsed);
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.Null(snapshot.UptimeSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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,
|
||||
NetLinkMbps = 1000,
|
||||
};
|
||||
|
||||
[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>());
|
||||
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.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 MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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(MoShiroChannels.RawChunk, garbage.Value, corrupt));
|
||||
|
||||
// 冒名的块:agent_id 与认证的节点不一致
|
||||
RawChunk spoofed = ChunkFactory.UdpEchoClean(new NodeId("agt_someone_else"), start);
|
||||
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MoShiroChannels.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,81 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class LinkTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public LinkTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SetLinkAsync(string probe, string agent, bool? enabled) =>
|
||||
await _backend.Admin.PutAsync($"/api/admin/links/{probe}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled }));
|
||||
|
||||
private async Task SetAutoLinkAsync(bool on)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["fleet.auto_link"] = on }, "application/merge-patch+json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<JsonNode?> LinkAsync(string probe, string agent) =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["probe_id"]!.GetValue<string>() == probe && i["agent_id"]!.GetValue<string>() == agent);
|
||||
|
||||
[Fact]
|
||||
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>();
|
||||
string agentId = agent.NodeId.Value;
|
||||
try
|
||||
{
|
||||
// 自动连接(默认开):新 Probe 自动进入这个 Agent 允许的名单
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
JsonNode link = (await LinkAsync(probe, agentId))!;
|
||||
Assert.True(link["linked"]!.GetValue<bool>());
|
||||
Assert.Null(link["override"]);
|
||||
|
||||
// 手动关掉这一对:自动连接开着也不连
|
||||
using HttpResponseMessage off = await SetLinkAsync(probe, agentId, false);
|
||||
Assert.Equal(HttpStatusCode.OK, off.StatusCode);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe));
|
||||
Assert.False((await LinkAsync(probe, agentId))!["linked"]!.GetValue<bool>());
|
||||
|
||||
// 取消手动设置:回到自动连接
|
||||
using HttpResponseMessage reset = await SetLinkAsync(probe, agentId, null);
|
||||
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
|
||||
// 关掉自动连接:没有手动选中的就都不连
|
||||
await SetAutoLinkAsync(false);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe));
|
||||
Assert.False((await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["auto_link"]!.GetValue<bool>());
|
||||
|
||||
// 自动连接关着,手动选中的照样连
|
||||
await SetLinkAsync(probe, agentId, true);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
Assert.True((await LinkAsync(probe, agentId))!["override"]!.GetValue<bool>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetLinkAsync(probe, agentId, null);
|
||||
await SetAutoLinkAsync(true); // 共用的后端:恢复默认,别影响别的测试
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Links_RefuseUnknownNodes()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
string probe = (await _backend.CreateProbeAsync())["probe_id"]!.GetValue<string>();
|
||||
using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode);
|
||||
using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, noAgent.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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 MoShiro.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\MoShiro.Backend\MoShiro.Backend.csproj" />
|
||||
<ProjectReference Include="..\MoShiro.TestSupport\MoShiro.TestSupport.csproj" />
|
||||
<ProjectReference Include="..\..\agent\src\MoShiro.Agent\MoShiro.Agent.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
|
||||
<ProjectReference Include="..\..\probe\src\MoShiro.Probe\MoShiro.Probe.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
public sealed partial class NodeInfoTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
|
||||
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
|
||||
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
|
||||
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
|
||||
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
|
||||
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
|
||||
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
|
||||
{
|
||||
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
|
||||
{
|
||||
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
|
||||
Assert.Equal("other", key);
|
||||
Assert.Equal("Hangzhou Alibaba Advertising", name);
|
||||
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
|
||||
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("223.5.5.5", true)]
|
||||
[InlineData("2001:4860:4860::8888", true)]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("127.0.0.1", false)]
|
||||
[InlineData("10.1.2.3", false)]
|
||||
[InlineData("172.16.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("192.168.1.10", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("100.64.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("fe80::1", false)]
|
||||
[InlineData("fd00::1", false)]
|
||||
[InlineData("::ffff:192.168.1.1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("printer.local", false)]
|
||||
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, NodeInfoService.IsPublic(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
|
||||
{
|
||||
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
|
||||
var store = new MemoryStore();
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(store, geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
||||
Assert.Empty(geo.Calls);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
|
||||
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
|
||||
|
||||
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
|
||||
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
||||
|
||||
// 地址变了才重新查;过了一周也重新查
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
time.Advance(TimeSpan.FromDays(8));
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(3, geo.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
|
||||
{
|
||||
var geo = new FakeGeo(null);
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_PlatformIsSavedAndSurvivesARestart()
|
||||
{
|
||||
var store = new MemoryStore();
|
||||
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
|
||||
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
||||
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{"ip":"223.5.5.5","found":true,"is_private":false,
|
||||
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
|
||||
"city":{"names":{"zh-CN":"杭州市"}},
|
||||
"location":{"latitude":30.25,"longitude":120.21},
|
||||
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
|
||||
""");
|
||||
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
|
||||
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
|
||||
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
|
||||
private static partial Regex Ipv4();
|
||||
|
||||
private sealed class FakeGeo : IGeoLookup
|
||||
{
|
||||
private readonly GeoInfo? _result;
|
||||
|
||||
public FakeGeo(GeoInfo? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeMetaStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeMeta> _items = [];
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
|
||||
|
||||
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
|
||||
{
|
||||
_items[meta.Info.NodeId] = meta;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
|
||||
{
|
||||
_items.Remove(nodeId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
||||
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed partial class NodePrivacyTests
|
||||
{
|
||||
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public NodePrivacyTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
||||
JsonNode? agentNode = null;
|
||||
JsonNode? probeNode = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
||||
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(40),
|
||||
"the agent (hello) and the probe (agent list sync) both report their platform");
|
||||
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
||||
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
||||
|
||||
string[] paths =
|
||||
[
|
||||
"/api/public/nodes",
|
||||
"/api/public/hosts",
|
||||
"/api/public/overview?range=1h&metrics=rtt.p50",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
||||
"/api/public/catalog/plans",
|
||||
];
|
||||
foreach (string path in paths)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotMatch(Ipv4(), body);
|
||||
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
|
||||
foreach (string key in KeysOf(JsonNode.Parse(body)))
|
||||
{
|
||||
Assert.DoesNotContain(key, s_forbiddenKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
|
||||
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
|
||||
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.False(about["show_build"]!.GetValue<bool>());
|
||||
Assert.Null(about["backend"]);
|
||||
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
Assert.All(hidden["items"]!.AsArray(), n =>
|
||||
{
|
||||
Assert.Null(n!["version"]);
|
||||
Assert.Null(n["tag"]);
|
||||
});
|
||||
|
||||
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
|
||||
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent reports its version in hello");
|
||||
|
||||
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
|
||||
on.EnsureSuccessStatusCode();
|
||||
try
|
||||
{
|
||||
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
|
||||
Assert.True(shown["show_build"]!.GetValue<bool>());
|
||||
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
|
||||
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
|
||||
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
|
||||
off.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> KeysOf(JsonNode? node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach ((string key, JsonNode? value) in obj)
|
||||
{
|
||||
yield return key;
|
||||
foreach (string inner in KeysOf(value))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case JsonArray array:
|
||||
foreach (JsonNode? item in array)
|
||||
{
|
||||
foreach (string inner in KeysOf(item))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
||||
private static partial Regex Ipv4();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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.moshiro.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 MoShiro.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,292 @@
|
||||
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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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", "moshiro.exec", "moshiro.write_file", "fleet.update" })
|
||||
{
|
||||
RpcMessage reply = await agent.RequestRawAsync(method);
|
||||
Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code);
|
||||
}
|
||||
|
||||
// 连接仍然可用:路由器照常回复
|
||||
Assert.Equal("fleet_unsupported", (await agent.RequestRawAsync("fleet.exec")).Error!.Data!.Code);
|
||||
}
|
||||
|
||||
[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);
|
||||
// 状态与事件也不会被接受:监控数据里没有它
|
||||
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 MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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,129 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
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_GetsTheAgentListFromThePanel_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 panel sends the agent list to the probe, which then connects");
|
||||
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_IntroducesItselfToThePanel_AndARevokedProbeIsLockedOut()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
string path = "/api/admin/probes/" + probe.ProbeId;
|
||||
|
||||
// 直连面板:在线,hello 里的系统、架构和版本都被记下来了
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue<bool>(), because: "the probe links to the panel");
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes"))["items"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == probe.ProbeId && n["os"] is not null && n["arch"] is not null),
|
||||
because: "the platform comes from the probe's own hello");
|
||||
|
||||
// 吊销:面板立刻断开它,之后连不上
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync(path + "/revoke", null);
|
||||
revoked.EnsureSuccessStatusCode();
|
||||
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue<bool>(), because: "a revoked probe is disconnected and cannot come back");
|
||||
Assert.False(probe.App.Link.IsConnected);
|
||||
}
|
||||
|
||||
[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, ["duration_s"] = 5 } }));
|
||||
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: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two 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 MoShiro.Probe;
|
||||
|
||||
namespace MoShiro.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(), "moshiro-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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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[] { MoShiroErrors.InvalidRange, MoShiroErrors.RangeTooLarge });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RangeResolver_EnforcesThePointLimit_AndSuggestsAStep()
|
||||
{
|
||||
Result<QueryRange> result = Resolver.Resolve(new RangeRequest("30d", null, null, "1m", null), s_now);
|
||||
Assert.Equal(MoShiroErrors.RangeTooLarge, result.Error.Code);
|
||||
Assert.Contains("step=", result.Error.Message);
|
||||
Assert.Equal(MoShiroErrors.InvalidStep, Resolver.Resolve(new RangeRequest("1h", null, null, "7s", null), s_now).Error.Code);
|
||||
Assert.Equal(MoShiroErrors.InvalidRange, Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now), DateTimeOffset.FromUnixTimeMilliseconds(s_now), null, null), s_now).Error.Code);
|
||||
Assert.Equal(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.UnknownMetric, MetricSelection.Resolve("nope", null, 100).Error.Code);
|
||||
Assert.Equal(MoShiroErrors.UnknownMetric, MetricSelection.Resolve("score.nope", null, 100).Error.Code);
|
||||
Assert.Equal(MoShiroErrors.UnknownPreset, MetricSelection.Resolve(null, "nope", 100).Error.Code);
|
||||
Assert.Equal(MoShiroErrors.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 MoShiro.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 MoShiro.Backend.Analysis;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Backend.Storage.Sqlite;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.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(), "moshiro-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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(MoShiroErrors.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(), "moshiro-schema-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
using (var connection = new SqliteConnection($"Data Source={Path.Combine(dir, "moshiro.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class UpdateTests
|
||||
{
|
||||
private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
// 每个用例取自己的 seq(单调递增):共用的后端只接受比已登记的更大的 seq,用例的先后顺序不能影响结果
|
||||
private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000;
|
||||
|
||||
private static long NextSeq() => Interlocked.Add(ref s_seq, 100);
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public UpdateTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
// 面板不验签(节点宿主机上的更新器验),所以测试里签名随便写一个格式对的
|
||||
private static string Manifest(string component, long seq, string digest = Digest) =>
|
||||
$$"""{"schema":1,"component":"{{component}}","channel":"dev","image":"registry.example/moshiro-dev/moshiro-{{component}}","digest":"{{digest}}","tag":"dev-{{seq}}","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":{{seq}}}""";
|
||||
|
||||
private async Task<HttpResponseMessage> PublishAsync(string manifest, string signature = "MEUCIQDc") =>
|
||||
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||
|
||||
[Fact]
|
||||
public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater()
|
||||
{
|
||||
long seq = NextSeq();
|
||||
await using AgentSimulator online = await _backend.ConnectAgentAsync();
|
||||
string manifest = Manifest("agent", seq);
|
||||
|
||||
using HttpResponseMessage published = await PublishAsync(manifest);
|
||||
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
||||
JsonNode release = JsonNode.Parse(await published.Content.ReadAsStringAsync())!;
|
||||
Assert.Equal("agent", release["component"]!.GetValue<string>());
|
||||
Assert.Equal(Digest, release["digest"]!.GetValue<string>());
|
||||
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == "MEUCIQDc")), because: "the offer reaches online agents unchanged");
|
||||
|
||||
// 之后才连上的节点,hello 之后补发
|
||||
await using AgentSimulator late = await _backend.ConnectAgentAsync();
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(late.UpdateOffers.ToArray().Any(o => o.Manifest == manifest)), because: "a node that connects later still gets the latest offer");
|
||||
|
||||
// 另一个组件(probe)的清单不会发给 Agent
|
||||
using HttpResponseMessage probeRelease = await PublishAsync(Manifest("probe", seq));
|
||||
Assert.Equal(HttpStatusCode.Created, probeRelease.StatusCode);
|
||||
await Task.Delay(300);
|
||||
Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"probe\"", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_Registration_RefusesBadManifests_StaleSeq_AndNeedsAnAdmin()
|
||||
{
|
||||
long seq = NextSeq();
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("backend", seq))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq))).StatusCode); // 同一个 seq:重放
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq - 1))).StatusCode); // 更旧的 seq
|
||||
|
||||
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = "MEUC" }));
|
||||
Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
|
||||
{
|
||||
async Task<string> NewTokenAsync(string scope)
|
||||
{
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
HttpClient Client(string token)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = _backend.BaseAddress };
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
long seq = NextSeq();
|
||||
using HttpClient release = Client(await NewTokenAsync("release"));
|
||||
using HttpClient read = Client(await NewTokenAsync("read"));
|
||||
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
|
||||
|
||||
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body);
|
||||
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
||||
|
||||
// release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRequest_ReachesTheNode_AndItsReportedStatusShowsInBuildInfo()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
using HttpResponseMessage request = await _backend.Admin.PostAsync($"/api/admin/nodes/{agent.NodeId.Value}/update", null);
|
||||
Assert.Equal(HttpStatusCode.NoContent, request.StatusCode);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.UpdateApplies == 1));
|
||||
|
||||
// 节点(宿主机更新器)报告状态:面板在构建信息里显示
|
||||
await agent.SendUpdateStatusAsync(new UpdateStatus { State = "available", Available = new UpdateAvailable { Tag = "dev-9", Version = "0.1.1", Digest = Digest, Seq = 9 }, At = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() });
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
JsonNode? node = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.NodeId.Value);
|
||||
return node?["update"]?["state"]?.GetValue<string>() == "available" && node["update"]!["available_tag"]!.GetValue<string>() == "dev-9";
|
||||
}, because: "the update state comes from the node's heartbeat");
|
||||
|
||||
// 节点离线之后不能请求更新
|
||||
string id = agent.NodeId.Value;
|
||||
await agent.DisposeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
using HttpResponseMessage offline = await _backend.Admin.PostAsync($"/api/admin/nodes/{id}/update", null);
|
||||
return offline.StatusCode == HttpStatusCode.Conflict || offline.StatusCode == HttpStatusCode.NotFound;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user