Files
MoShiro/backend/tests/MoShiro.Backend.Tests/HostTests.cs
T
justme 2d31bd091e
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/dev-image Pipeline failed
ci: gate on the fast tests (about 20s); slow end-to-end tests run in parallel with the image build [CI]
2026-10-09 14:33:03 +08:00

241 lines
12 KiB
C#

using System.Text.Json.Nodes;
using MoShiro.Agent;
using MoShiro.Measurement;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
public sealed class HostSamplerTests
{
private const string Stat1 = "cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 0 0 0 0 0\n";
private const string Stat2 = "cpu 200 0 200 800 100 0 0 0 0 0\n"; // 比上一次:总时间 +300,其中空闲 +100
private const string Dev1 = "Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 10 0 0 0 0 0 0 5000 10 0 0 0 0 0 0\ndocker0: 777 1 0 0 0 0 0 0 777 1 0 0 0 0 0 0\n";
private const string Dev2 = "Inter-| Receive\n face |bytes\n eth0: 3000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0\n";
[Fact]
public void Parsers_ReadTheProcFormats_AndSkipLoopbackAndContainerInterfaces()
{
Assert.Equal((1000L, 800L), HostSampler.ParseCpu(Stat1));
Assert.Equal((8L * 1024 * 1024, 2L * 1024 * 1024), HostSampler.ParseMemory("MemTotal: 8192 kB\nMemFree: 100 kB\nMemAvailable: 6144 kB\n"));
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1));
Assert.Equal(3600L, HostSampler.ParseUptime("3600.55 7000.1\n"));
Assert.Null(HostSampler.ParseNetwork("nothing useful"));
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n"));
}
[Fact]
public async Task Sample_OnWindows_ReadsCpuMemoryAndNetworkFromTheSystem()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var sampler = new HostSampler(TimeProvider.System);
sampler.Sample();
await Task.Delay(300, TestContext.Current.CancellationToken);
HostSnapshot snapshot = sampler.Sample();
Assert.NotNull(snapshot.CpuPercent);
Assert.InRange(snapshot.CpuPercent!.Value, 0, 100);
Assert.True(snapshot.MemTotal > 0 && snapshot.MemUsed >= 0 && snapshot.MemUsed <= snapshot.MemTotal);
}
[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.Replace('\\', '/') switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
HostSnapshot first = sampler.Sample();
Assert.Null(first.CpuPercent); // 第一次没有基线
Assert.Null(first.NetRxBps);
Assert.Equal(250, first.DiskUsed);
Assert.Equal(100, first.UptimeSeconds);
time.Advance(TimeSpan.FromSeconds(2));
stat = Stat2;
dev = Dev2;
HostSnapshot second = sampler.Sample();
Assert.Equal(66.7, second.CpuPercent); // 总时间 +300、空闲 +100:忙碌 200/300
Assert.Equal(1000, second.NetRxBps); // (3000 - 1000) / 2 s
Assert.Equal(2000, second.NetTxBps);
Assert.Equal(3000, second.NetRxTotal);
}
[Fact]
public void Sample_UsesMountedHostProcAndSys_InsteadOfContainerPaths()
{
var reads = new List<string>();
var sampler = new HostSampler(TimeProvider.System, path =>
{
path = path.Replace('\\', '/'); // 测试里的 /proc 格式统一用斜杠,兼容 Windows 的 Path.Combine
reads.Add(path);
return path switch
{
"/host/proc/stat" => Stat1,
"/host/proc/meminfo" => "MemTotal: 8192 kB\nMemAvailable: 6144 kB\n",
"/host/proc/net/dev" => Dev1,
"/host/proc/uptime" => "3600.0 7000.0",
"/host/sys/class/net/eth0/speed" => "10000\n",
_ => throw new InvalidOperationException("unexpected container path: " + path),
};
}, () => (1000, 250), procRoot: "/host/proc", sysRoot: "/host/sys");
HostSnapshot snapshot = sampler.Sample();
Assert.Equal(8L * 1024 * 1024, snapshot.MemTotal);
Assert.Equal(1000, snapshot.NetRxTotal);
Assert.Equal(10000, snapshot.NetLinkMbps);
Assert.Equal(3600, snapshot.UptimeSeconds);
Assert.DoesNotContain(reads, path => path.StartsWith("/proc/", StringComparison.Ordinal));
Assert.DoesNotContain(reads, path => path.Contains("docker0", StringComparison.Ordinal) || path.Contains("/lo/", StringComparison.Ordinal));
}
[Theory]
[InlineData(null)]
[InlineData("-1")]
[InlineData("0")]
[InlineData("not supported")]
public void Sample_DoesNotInventLinkSpeedOrContainerDisk_WhenHostDataIsUnavailable(string? speed)
{
var sampler = new HostSampler(TimeProvider.System,
path => path.EndsWith("net/dev", StringComparison.Ordinal) ? Dev1 : path.EndsWith("/speed", StringComparison.Ordinal) ? speed : null,
procRoot: "/host/proc", sysRoot: "/host/sys", diskRoot: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
HostSnapshot snapshot = sampler.Sample();
Assert.Null(snapshot.NetLinkMbps);
Assert.Null(snapshot.DiskTotal);
Assert.Null(snapshot.DiskUsed);
if (OperatingSystem.IsLinux())
{
Assert.Null(snapshot.UptimeSeconds);
}
}
}
[Collection("backend")]
public sealed class HostStatusTests
{
private readonly BackendFixture _backend;
public HostStatusTests(BackendFixture backend)
{
_backend = backend;
}
private static HostSnapshot Snapshot(double cpu) => new()
{
CpuPercent = cpu, MemUsed = 2L << 30, MemTotal = 8L << 30, DiskUsed = 10L << 30, DiskTotal = 40L << 30,
NetRxTotal = 600L << 30, NetTxTotal = 180L << 30, NetRxBps = 2500, NetTxBps = 4800, UptimeSeconds = 3_000_000,
NetLinkMbps = 1000,
};
[Fact]
public async Task AgentHostSnapshots_AreVisibleToTheDisplayInRealTime_StoredAsHistory_AndNeverCarryModelsOrAddresses()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
await agent.SendHostAsync(Snapshot(18.5));
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue<string>() == id);
Assert.Equal("agent", live.Data!["kind"]!.GetValue<string>());
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!["id"]!.GetValue<string>() == id)!;
Assert.Equal("agent", item["kind"]!.GetValue<string>());
Assert.False(item["stale"]!.GetValue<bool>());
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
// 展示页能拿到的只有汇总数字:整份响应里没有型号、主机名、地址之类的字段
string text = list.ToJsonString();
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
{
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
}
// 线路详情也不泄漏 Probe 的地址和版本
string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString();
Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("probe_version", line, StringComparison.OrdinalIgnoreCase);
// 历史:每分钟一个样本
JsonNode history = null!;
await BackendFixture.EventuallyAsync(async () =>
{
history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/hosts/{id}?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!["id"]!.GetValue<string>() == id);
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/hosts/{id}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode);
}
[Fact]
public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked()
{
await _backend.SiteMustBePublicAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1);
JsonNode? item = null;
await BackendFixture.EventuallyAsync(
async () =>
{
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue<string>() == probe.ProbeId);
return item?["host"] is not null;
},
TimeSpan.FromSeconds(30),
"the probe's host status shows up");
Assert.Equal("probe", item!["kind"]!.GetValue<string>());
Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
{
Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase);
}
// 历史也按节点 id 取
using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h");
Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode);
// 吊销之后展示页就看不到它了
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null);
Assert.True(revoked.IsSuccessStatusCode);
JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == probe.ProbeId);
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode);
}
[Fact]
[Trait("Category", "Slow")]
public async Task AProbeThatTurnsReportingOff_SendsNothing()
{
await _backend.SiteMustBePublicAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file =>
{
file.Reporting.HostStats = false;
file.Reporting.HostIntervalSeconds = 1;
});
await Task.Delay(TimeSpan.FromSeconds(4));
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == probe.ProbeId)!;
Assert.Null(item["host"]); // 节点还在,但一个字也没上报
}
}