Files
MoShiro/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs
T
justme 238046d959
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
test(backend): parallelize isolated fixtures [CI] [SKIP TESTS]
2026-10-10 04:54:18 +08:00

310 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Time.Testing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Tests;
public sealed partial class NodeInfoTests
{
[Theory]
[InlineData(9808, "China Mobile Communications Group", "mobile", "中国移动")]
[InlineData(4134, "Chinanet", "telecom", "中国电信")]
[InlineData(4837, "China Unicom Backbone", "unicom", "中国联通")]
[InlineData(null, "China Mobile Communications Corporation", "mobile", "中国移动")]
[InlineData(null, "CHINANET-BACKBONE", "telecom", "中国电信")]
[InlineData(null, "China Unicom Beijing Province Network", "unicom", "中国联通")]
[InlineData(null, "China Broadnet Corporation", "broadnet", "中国广电")]
public void IspClassifier_RecognizesTheChineseCarriers(int? asn, string org, string key, string name)
{
Assert.Equal((key, name), IspClassifier.Classify(asn, org));
}
[Fact]
public void IspClassifier_OtherCarriersKeepAShortenedOrganizationName()
{
(string key, string name) = IspClassifier.Classify(37963, "Hangzhou Alibaba Advertising Co.,Ltd.");
Assert.Equal("other", key);
Assert.Equal("Hangzhou Alibaba Advertising", name);
Assert.Equal("Google", IspClassifier.Classify(15169, "Google LLC").Name);
Assert.True(IspClassifier.Classify(1, new string('x', 80)).Name.Length <= 28);
}
[Theory]
[InlineData("8.8.8.8", true)]
[InlineData("223.5.5.5", true)]
[InlineData("2001:4860:4860::8888", true)]
[InlineData("example.com", true)]
[InlineData("127.0.0.1", false)]
[InlineData("10.1.2.3", false)]
[InlineData("172.16.0.1", false)]
[InlineData("172.32.0.1", true)]
[InlineData("192.168.1.10", false)]
[InlineData("169.254.1.1", false)]
[InlineData("100.64.0.1", false)]
[InlineData("0.0.0.0", false)]
[InlineData("::1", false)]
[InlineData("fe80::1", false)]
[InlineData("fd00::1", false)]
[InlineData("::ffff:192.168.1.1", false)]
[InlineData("localhost", false)]
[InlineData("printer.local", false)]
public void OnlyPublicAddressesAreEverSentToTheLookupService(string host, bool expected)
{
Assert.Equal(expected, NodeInfoService.IsPublic(host));
}
[Fact]
public async Task Service_LooksAPublicAddressUpOnce_KeepsOnlyCountryAndIsp_AndNeverQueriesPrivateOnes()
{
var geo = new FakeGeo(new GeoInfo("cn", 9808, "China Mobile Communications Group", false));
var store = new MemoryStore();
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
var service = new NodeInfoService(store, geo, time);
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
Assert.Empty(geo.Calls);
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
Assert.Single(geo.Calls);
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
Assert.Equal(("CN", "mobile", "中国移动"), (info.Country, info.IspKey, info.IspName));
// 展示用的信息里没有地址:整个对象序列化出来,找不到任何 IP 形状的字符串
Assert.DoesNotMatch(Ipv4(), info.ToString());
// 地址变了才重新查;过了一周也重新查
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
Assert.Equal(2, geo.Calls.Count);
time.Advance(TimeSpan.FromDays(8));
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
Assert.Equal(3, geo.Calls.Count);
}
[Fact]
public async Task Service_FailedLookups_AreNotRetriedForAWhile_AndNeverThrow()
{
var geo = new FakeGeo(null);
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
var service = new NodeInfoService(new MemoryStore(), geo, time);
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
Assert.Single(geo.Calls);
time.Advance(TimeSpan.FromMinutes(11));
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
Assert.Equal(2, geo.Calls.Count);
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
}
[Fact]
public async Task Service_PlatformIsSavedAndSurvivesARestart()
{
var store = new MemoryStore();
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
Assert.Equal(("linux", "Debian 12", "arm64"), (info.Os, info.OsName, info.Arch));
}
[Fact]
public void GeoParser_ReadsOnlyCountryAsnAndOrganization()
{
using JsonDocument doc = JsonDocument.Parse("""
{"ip":"223.5.5.5","found":true,"is_private":false,
"country_region":{"iso_code":"CN","subdivisions":[{"names":{"zh-CN":"浙江省"}}]},
"city":{"names":{"zh-CN":"杭州市"}},
"location":{"latitude":30.25,"longitude":120.21},
"network":{"cidr":"223.4.0.0/14","asn":37963,"organization":"Hangzhou Alibaba Advertising Co.,Ltd.","isp":""}}
""");
GeoInfo? geo = HttpGeoLookup.Parse(doc.RootElement);
Assert.Equal(new GeoInfo("CN", 37963, "Hangzhou Alibaba Advertising Co.,Ltd.", false), geo);
Assert.Null(HttpGeoLookup.Parse(JsonDocument.Parse("""{"found":false}""").RootElement));
}
[GeneratedRegex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")]
private static partial Regex Ipv4();
private sealed class FakeGeo : IGeoLookup
{
private readonly GeoInfo? _result;
public FakeGeo(GeoInfo? result)
{
_result = result;
}
public List<string> Calls { get; } = [];
public ValueTask<GeoInfo?> LookupAsync(string host, CancellationToken ct)
{
Calls.Add(host);
return ValueTask.FromResult(_result);
}
}
private sealed class MemoryStore : INodeMetaStore
{
private readonly Dictionary<string, NodeMeta> _items = [];
public ValueTask<IReadOnlyList<NodeMeta>> ListAsync(CancellationToken ct) => ValueTask.FromResult<IReadOnlyList<NodeMeta>>([.. _items.Values]);
public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct)
{
_items[meta.Info.NodeId] = meta;
return ValueTask.CompletedTask;
}
public ValueTask DeleteAsync(string nodeId, CancellationToken ct)
{
_items.Remove(nodeId);
return ValueTask.CompletedTask;
}
}
}
/// <summary>
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
/// </summary>
public sealed partial class NodePrivacyTests : IClassFixture<BackendFixture>
{
private static readonly string[] s_forbiddenKeys = ["ip", "city", "asn", "latitude", "longitude", "cidr", "remote", "public_host", "geo_host", "host_name", "hostname"];
private readonly BackendFixture _backend;
public NodePrivacyTests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public async Task PublicEndpoints_NeverExposeAnAddress_AndNodesCarryOnlyPlatformCountryAndIsp()
{
await _backend.SiteMustBePublicAsync();
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync();
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
JsonNode? agentNode = null;
JsonNode? probeNode = null;
await BackendFixture.EventuallyAsync(
async () =>
{
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
},
TimeSpan.FromSeconds(40),
"the agent (hello) and the probe (agent list sync) both report their platform");
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
string[] paths =
[
"/api/public/nodes",
"/api/public/hosts",
"/api/public/overview?range=1h&metrics=rtt.p50",
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
"/api/public/catalog/plans",
];
foreach (string path in paths)
{
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode}");
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotMatch(Ipv4(), body);
Assert.DoesNotContain("::1", body, StringComparison.Ordinal);
foreach (string key in KeysOf(JsonNode.Parse(body)))
{
Assert.DoesNotContain(key, s_forbiddenKeys);
}
}
}
[Fact]
public async Task BuildInfo_IsHiddenFromVisitorsUntilTheSwitchIsOn_AndAlwaysVisibleToAdmins()
{
await _backend.SiteMustBePublicAsync();
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
// 默认关:访客看不到版本和 tag,「关于」里只有 show_build: false
JsonNode about = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
Assert.False(about["show_build"]!.GetValue<bool>());
Assert.Null(about["backend"]);
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
Assert.All(hidden["items"]!.AsArray(), n =>
{
Assert.Null(n!["version"]);
Assert.Null(n["tag"]);
});
// 管理端一直看得到:后端自己的版本和每个节点上报的版本
JsonNode admin = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
Assert.False(string.IsNullOrEmpty(admin["backend"]!["version"]!.GetValue<string>()));
await BackendFixture.EventuallyAsync(
async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"))["nodes"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == agent.Id && n["version"] is not null),
TimeSpan.FromSeconds(30),
"the agent reports its version in hello");
using HttpResponseMessage on = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = true }, "application/merge-patch+json"));
on.EnsureSuccessStatusCode();
try
{
JsonNode shown = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/about");
Assert.True(shown["show_build"]!.GetValue<bool>());
Assert.False(string.IsNullOrEmpty(shown["backend"]!["version"]!.GetValue<string>()));
Assert.Null(shown["backend"]!["commit"]); // 访客只看版本、tag、构建时间
JsonNode visible = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
JsonNode self = visible["items"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!;
Assert.False(string.IsNullOrEmpty(self["version"]!.GetValue<string>()));
}
finally
{
using HttpResponseMessage off = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.show_build"] = false }, "application/merge-patch+json"));
off.EnsureSuccessStatusCode();
}
}
private static IEnumerable<string> KeysOf(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
foreach ((string key, JsonNode? value) in obj)
{
yield return key;
foreach (string inner in KeysOf(value))
{
yield return inner;
}
}
break;
case JsonArray array:
foreach (JsonNode? item in array)
{
foreach (string inner in KeysOf(item))
{
yield return inner;
}
}
break;
}
}
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
private static partial Regex Ipv4();
}