using System.Net; using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Tests; public sealed class BackendUpdateTests : IAsyncLifetime { private const string Digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private readonly BackendFixture _backend = new(); public async ValueTask InitializeAsync() { await _backend.InitializeAsync(); BackendOptions options = _backend.Services.GetRequiredService(); options.UpdateChannel = "dev"; options.UpdateMode = "restart"; } public ValueTask DisposeAsync() => _backend.DisposeAsync(); [Theory] [InlineData("moshiro-dev", "dev")] [InlineData("registry.example/team/moshiro-dev", "dev")] [InlineData("moshiro", "release")] [InlineData("registry.example/team/moshiro", "release")] public void AutoChannel_FollowsTheConfiguredImageRepository(string imageNamespace, string expected) { Assert.Equal(expected, BackendUpdates.ChannelOf(new BackendOptions { UpdateChannel = "auto", ImageNamespace = imageNamespace })); } [Theory] [InlineData("v0.4.1-dev.12-044ce0c", true)] [InlineData("dev-725fe17a7de8", true)] [InlineData("main-725fe17a7d", true)] [InlineData("v0.4.0", false)] [InlineData("v0.4.1-rc.1", false)] [InlineData(null, false)] public void IsDevTag_RecognisesDevelopmentBuilds(string? tag, bool expected) { Assert.Equal(expected, BackendUpdates.IsDevTag(tag)); } private async Task PublishAsync(long seq, string channel = "dev") { string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}"""; return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); } private Task ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); [Fact] public async Task Webhook_PreservesChannels_AndNeverConfirmsAnUpdateByItself() { using HttpResponseMessage dev = await PublishAsync(1); using HttpResponseMessage release = await PublishAsync(1, "release"); Assert.Equal(HttpStatusCode.Created, dev.StatusCode); Assert.Equal(HttpStatusCode.Created, release.StatusCode); JsonNode view = await ViewAsync(); Assert.Equal("dev", view["channel"]!.GetValue()); Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue()); Assert.Equal("available", view["state"]!.GetValue()); Assert.Equal(0, _backend.Restarts.Count); // 发布只提示,不会自己重启 ReleaseOffer? stable = await _backend.Services.GetRequiredService().GetBackendAsync("release", default); Assert.Equal("release-1", stable!.Tag); using HttpResponseMessage replay = await PublishAsync(1); Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode); } [Fact] public async Task Confirm_BacksUpTheDatabase_ThenExits_AndRejectsASupersededSequence() { using HttpResponseMessage first = await PublishAsync(10); Assert.Equal(HttpStatusCode.Created, first.StatusCode); using HttpResponseMessage wrong = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 9 })); Assert.Equal(HttpStatusCode.PreconditionFailed, wrong.StatusCode); Assert.Equal(0, _backend.Restarts.Count); using HttpResponseMessage accepted = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode); Assert.Equal(1, _backend.Restarts.Count); Assert.Equal("applying", (await ViewAsync())["state"]!.GetValue()); string backup = Assert.Single(Directory.GetFiles(Path.Combine(_backend.DataDirectory, "backups"), "pre-update-*.db")); Assert.True(new FileInfo(backup).Length > 0); using HttpResponseMessage second = await PublishAsync(11); Assert.Equal(HttpStatusCode.Created, second.StatusCode); using HttpResponseMessage stale = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); Assert.Equal(1, _backend.Restarts.Count); // 新清单没有被悄悄当成已确认 using HttpResponseMessage newConfirmation = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 11 })); Assert.Equal(HttpStatusCode.NoContent, newConfirmation.StatusCode); Assert.Equal(2, _backend.Restarts.Count); } [Fact] public async Task Confirm_IsRefusedWithoutRestartMode() { _backend.Services.GetRequiredService().UpdateMode = null; using HttpResponseMessage published = await PublishAsync(30); Assert.Equal(HttpStatusCode.Created, published.StatusCode); Assert.False((await ViewAsync())["enabled"]!.GetValue()); using HttpResponseMessage refused = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 30 })); Assert.Equal(HttpStatusCode.PreconditionFailed, refused.StatusCode); Assert.Equal(0, _backend.Restarts.Count); } [Fact] public async Task Live_PushesCurrentChannel_AndAnonymousCannotReadOrConfirm() { using HttpResponseMessage denied = await _backend.Anonymous.GetAsync("/api/admin/backend/update"); Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); using HttpResponseMessage cannotConfirm = await _backend.Anonymous.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 1 })); Assert.Equal(HttpStatusCode.Unauthorized, cannotConfirm.StatusCode); await using SseClient live = await SseClient.ConnectAsync(_backend.Admin, "/api/admin/backend/update/live"); await live.WaitForAsync(e => e.Name == "backend_update"); using HttpResponseMessage release = await PublishAsync(20); Assert.Equal(HttpStatusCode.Created, release.StatusCode); SseEvent updated = await live.WaitForAsync(e => e.Name == "backend_update" && e.Data?["available"]?["seq"]?.GetValue() == 20); Assert.Equal("dev", updated.Data!["channel"]!.GetValue()); Assert.Equal(0, _backend.Restarts.Count); } }