using System.Net; using System.Text.Json.Nodes; using Monitor.Protocol; using Monitor.TestSupport; namespace Monitor.Backend.Tests; [Collection("probe-e2e")] public sealed class UpdateTests { private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; private readonly BackendFixture _backend; public UpdateTests(BackendFixture backend) { _backend = backend; } // 面板不验签(节点宿主机上的更新器验),所以测试里签名随便写一个格式对的 private static string Manifest(string component, long seq, string digest = Digest) => $$"""{"schema":1,"component":"{{component}}","channel":"dev","image":"registry.example/monitor-dev/monitor-{{component}}","digest":"{{digest}}","tag":"dev-{{seq}}","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":{{seq}}}"""; private async Task PublishAsync(string manifest, string signature = "MEUCIQDc") => await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); [Fact] public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater() { long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; await using AgentSimulator online = await _backend.ConnectAgentAsync(); string manifest = Manifest("agent", seq); using HttpResponseMessage published = await PublishAsync(manifest); Assert.Equal(HttpStatusCode.Created, published.StatusCode); JsonNode release = JsonNode.Parse(await published.Content.ReadAsStringAsync())!; Assert.Equal("agent", release["component"]!.GetValue()); Assert.Equal(Digest, release["digest"]!.GetValue()); await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == "MEUCIQDc")), because: "the offer reaches online agents unchanged"); // 之后才连上的节点,hello 之后补发 await using AgentSimulator late = await _backend.ConnectAgentAsync(); await BackendFixture.EventuallyAsync(() => Task.FromResult(late.UpdateOffers.ToArray().Any(o => o.Manifest == manifest)), because: "a node that connects later still gets the latest offer"); // 另一个组件(probe)的清单不会发给 Agent using HttpResponseMessage probeRelease = await PublishAsync(Manifest("probe", seq)); Assert.Equal(HttpStatusCode.Created, probeRelease.StatusCode); await Task.Delay(300); Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"probe\"", StringComparison.Ordinal)); } [Fact] public async Task Release_Registration_RefusesBadManifests_StaleSeq_AndNeedsAnAdmin() { long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 5; Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("backend", seq))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode); Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq))).StatusCode); // 同一个 seq:重放 Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq - 1))).StatusCode); // 更旧的 seq using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = "MEUC" })); Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } [Fact] public async Task UpdateRequest_ReachesTheNode_AndItsReportedStatusShowsInBuildInfo() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); using HttpResponseMessage request = await _backend.Admin.PostAsync($"/api/admin/nodes/{agent.NodeId.Value}/update", null); Assert.Equal(HttpStatusCode.NoContent, request.StatusCode); await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.UpdateApplies == 1)); // 节点(宿主机更新器)报告状态:面板在构建信息里显示 await agent.SendUpdateStatusAsync(new UpdateStatus { State = "available", Available = new UpdateAvailable { Tag = "dev-9", Version = "0.1.1", Digest = Digest, Seq = 9 }, At = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() }); await BackendFixture.EventuallyAsync(async () => { JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info"); JsonNode? node = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == agent.NodeId.Value); return node?["update"]?["state"]?.GetValue() == "available" && node["update"]!["available_tag"]!.GetValue() == "dev-9"; }, because: "the update state comes from the node's heartbeat"); // 节点离线之后不能请求更新 string id = agent.NodeId.Value; await agent.DisposeAsync(); await BackendFixture.EventuallyAsync(async () => { using HttpResponseMessage offline = await _backend.Admin.PostAsync($"/api/admin/nodes/{id}/update", null); return offline.StatusCode == HttpStatusCode.Conflict || offline.StatusCode == HttpStatusCode.NotFound; }); } }