ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The official CI key is built in and always trusted; an own P-256 public key can be added (off by default, the private key is rejected) so manifests signed with it are accepted too. Removing it drops the manifests only it signed. Changes are audited and need update.confirm. Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on generating a key pair and signing a manifest with openssl. Registration now has one path, POST /api/release/offer (no token, signature verified). The admin POST /releases route, the release.publish capability and the ci-release preset are removed.
113 lines
6.4 KiB
C#
113 lines
6.4 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
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();
|
|
_backend.Services.GetRequiredService<BackendOptions>().UpdateChannel = "dev";
|
|
}
|
|
|
|
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 }));
|
|
}
|
|
|
|
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");
|
|
|
|
private async Task VerifiedAsync(long seq)
|
|
{
|
|
string directory = Path.Combine(_backend.DataDirectory, "update-status");
|
|
Directory.CreateDirectory(directory);
|
|
var status = new UpdateStatus
|
|
{
|
|
State = "available",
|
|
Current = "sha256:" + new string('b', 64),
|
|
Available = new UpdateAvailable { Seq = seq, Digest = Digest, Tag = "dev-" + seq, Version = "0.1.1" },
|
|
};
|
|
await File.WriteAllTextAsync(Path.Combine(directory, "status.json"), JsonSerializer.Serialize(status, MoShiroJsonContext.Default.UpdateStatus));
|
|
}
|
|
|
|
[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>());
|
|
string requestDirectory = Path.Combine(_backend.DataDirectory, "update");
|
|
Assert.False(File.Exists(Path.Combine(requestDirectory, "apply.request")));
|
|
Assert.Contains("\"channel\":\"dev\"", await File.ReadAllTextAsync(Path.Combine(requestDirectory, "offer.manifest")), StringComparison.Ordinal);
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Confirm_RequiresHostVerification_AndRejectsASupersededSequence()
|
|
{
|
|
using HttpResponseMessage first = await PublishAsync(10);
|
|
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
|
using HttpResponseMessage unverified = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 }));
|
|
Assert.Equal(HttpStatusCode.PreconditionFailed, unverified.StatusCode);
|
|
await VerifiedAsync(10);
|
|
using HttpResponseMessage accepted = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 }));
|
|
Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode);
|
|
string path = Path.Combine(_backend.DataDirectory, "update", "apply.request");
|
|
Assert.Equal("10", await File.ReadAllTextAsync(path));
|
|
Assert.Equal("requested", (await ViewAsync())["state"]!.GetValue<string>());
|
|
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("10", await File.ReadAllTextAsync(path)); // 新 webhook 不会把确认请求悄悄改成 11
|
|
await VerifiedAsync(11);
|
|
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("11", await File.ReadAllTextAsync(path));
|
|
}
|
|
|
|
[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.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request")));
|
|
}
|
|
}
|