Files
MoShiro/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs
T
justme 49e787a7a5
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful
backend: confirm a registered update by itself after a delay when auto update is switched on [CI]
2026-10-22 10:59:32 +08:00

153 lines
8.3 KiB
C#

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<BackendOptions>();
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<HttpResponseMessage> 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<JsonNode> 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<string>());
Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue<string>());
Assert.Equal("available", view["state"]!.GetValue<string>());
Assert.Equal(0, _backend.Restarts.Count); // 发布只提示,不会自己重启
ReleaseOffer? stable = await _backend.Services.GetRequiredService<ReleaseService>().GetBackendAsync("release", default);
Assert.Equal("release-1", stable!.Tag);
using HttpResponseMessage replay = await PublishAsync(1);
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
}
/// <summary>自动更新默认关;打开之后只在新版本登记满设定的时间才确认,走的是和手动一样的路径(先备份)。</summary>
[Fact]
public async Task AutoUpdate_IsOffByDefault_AndConfirmsOnlyAfterTheOfferHasAged()
{
BackendOptions options = _backend.Services.GetRequiredService<BackendOptions>();
var updater = _backend.Services.GetRequiredService<BackendAutoUpdater>();
using HttpResponseMessage old = await PublishAsync(10); // seq 是发布时间(秒):1970 年,早就过了等待时间
Assert.Equal(HttpStatusCode.Created, old.StatusCode);
Assert.False(await updater.RunOnceAsync(default)); // 默认关
Assert.Equal(0, _backend.Restarts.Count);
options.AutoUpdate = true;
options.AutoUpdateDelayMinutes = 10;
using HttpResponseMessage fresh = await PublishAsync(TimeProvider.System.GetUtcNow().ToUnixTimeSeconds());
Assert.Equal(HttpStatusCode.Created, fresh.StatusCode);
Assert.False(await updater.RunOnceAsync(default)); // 刚登记的版本还要等
Assert.Equal(0, _backend.Restarts.Count);
options.AutoUpdateDelayMinutes = 0;
Assert.True(await updater.RunOnceAsync(default));
Assert.Equal(1, _backend.Restarts.Count);
Assert.Single(Directory.GetFiles(Path.Combine(_backend.DataDirectory, "backups"), "pre-update-*.db")); // 先备份
Assert.False(await updater.RunOnceAsync(default)); // 已经在更新,不会再确认第二次
Assert.Equal(1, _backend.Restarts.Count);
}
[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>());
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<BackendOptions>().UpdateMode = null;
using HttpResponseMessage published = await PublishAsync(30);
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
Assert.False((await ViewAsync())["enabled"]!.GetValue<bool>());
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<long>() == 20);
Assert.Equal("dev", updated.Data!["channel"]!.GetValue<string>());
Assert.Equal(0, _backend.Restarts.Count);
}
}