chore: initial project import

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

No files matched your search

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