using System.Net; using System.Text.Json.Nodes; using MoShiro.Protocol; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; [Collection("probe-e2e")] public sealed class UpdateTests { private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; // 每个用例取自己的 seq(单调递增):共用的后端只接受比已登记的更大的 seq,用例的先后顺序不能影响结果 private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; private static long NextSeq() => Interlocked.Add(ref s_seq, 100); 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/moshiro-dev/moshiro-{{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 = null) { signature ??= TestReleaseKey.Sign(manifest); return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); } [Fact] public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater() { long seq = NextSeq(); await using AgentSimulator online = await _backend.ConnectAgentAsync(); string manifest = Manifest("agent", seq); string signature = TestReleaseKey.Sign(manifest); // ECDSA 每次签出来都不一样:签一次,登记和比较共用 using HttpResponseMessage published = await PublishAsync(manifest, signature); 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 == signature)), 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_BadSignatures_AndStaleSeq() { long seq = NextSeq(); 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("unknown", seq))).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode); using var other = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); string foreign = Convert.ToBase64String(other.SignData(System.Text.Encoding.UTF8.GetBytes(Manifest("agent", seq)), System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.DSASignatureFormat.Rfc3279DerSequence)); Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), foreign)).StatusCode); // 别人的密钥签的 Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), "MEUCIQDc")).StatusCode); // 格式对、签名不对 Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), TestReleaseKey.Sign(Manifest("agent", seq + 5)))).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 } [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; }); } }