chore: initial project import
This commit is contained in:
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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user