136 lines
8.2 KiB
C#
136 lines
8.2 KiB
C#
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<HttpResponseMessage> 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 = NextSeq();
|
|
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<string>());
|
|
Assert.Equal(Digest, release["digest"]!.GetValue<string>());
|
|
|
|
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 = 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.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 ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
|
|
{
|
|
async Task<string> NewTokenAsync(string scope)
|
|
{
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope }));
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
|
}
|
|
|
|
HttpClient Client(string token)
|
|
{
|
|
var client = new HttpClient { BaseAddress = _backend.BaseAddress };
|
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
return client;
|
|
}
|
|
|
|
long seq = NextSeq();
|
|
using HttpClient release = Client(await NewTokenAsync("release"));
|
|
using HttpClient read = Client(await NewTokenAsync("read"));
|
|
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
|
|
|
|
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
|
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
|
using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body);
|
|
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
|
|
|
// release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode);
|
|
}
|
|
|
|
[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<string>() == agent.NodeId.Value);
|
|
return node?["update"]?["state"]?.GetValue<string>() == "available" && node["update"]!["available_tag"]!.GetValue<string>() == "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;
|
|
});
|
|
}
|
|
}
|