Files
justme 238046d959
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
test(backend): parallelize isolated fixtures [CI] [SKIP TESTS]
2026-10-10 04:54:18 +08:00

123 lines
6.1 KiB
C#

using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。
/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。
/// </summary>
public sealed class ReleaseKeyTests : IClassFixture<BackendFixture>
{
private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 500_000;
private readonly BackendFixture _backend;
public ReleaseKeyTests(BackendFixture backend)
{
_backend = backend;
}
private static string Manifest(long seq) =>
$$"""{"schema":1,"component":"backend","channel":"dev","image":"registry.example/moshiro-dev/moshiro-backend","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","tag":"own-{{seq}}","version":"0.1.1","seq":{{seq}}}""";
private static string Sign(ECDsa key, string manifest) =>
Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
private async Task<HttpResponseMessage> PutKeyAsync(string? pem) =>
await _backend.Admin.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = pem }));
private async Task<HttpResponseMessage> OfferAsync(string manifest, string signature)
{
using HttpClient anonymous = _backend.NewClient();
return await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
}
[Fact]
public async Task ByDefaultOnlyTheOfficialKeyIsTrusted_AndItCannotBeRemoved()
{
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key");
Assert.Equal(HttpStatusCode.OK, cleared.StatusCode);
JsonNode keys = JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!;
Assert.Null(keys["custom"]);
Assert.Equal(64, keys["official"]!["fingerprint"]!.GetValue<string>().Length);
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", keys["official"]!["public_key"]!.GetValue<string>(), StringComparison.Ordinal);
}
[Theory]
[InlineData("")]
[InlineData("not a key")]
[InlineData(null)]
public async Task ABadKey_IsRefused(string? pem)
{
using HttpResponseMessage response = await PutKeyAsync(pem);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ThePrivateKey_IsNeverAccepted_AndOnlyP256IsAllowed()
{
using var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
using HttpResponseMessage privateKey = await PutKeyAsync(p256.ExportPkcs8PrivateKeyPem());
using HttpResponseMessage ecPrivateKey = await PutKeyAsync(p256.ExportECPrivateKeyPem());
using HttpResponseMessage otherCurve = await PutKeyAsync(p384.ExportSubjectPublicKeyInfoPem());
Assert.Equal(HttpStatusCode.BadRequest, privateKey.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, ecPrivateKey.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, otherCurve.StatusCode);
Assert.Contains("private key", await privateKey.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AManifestSignedByTheOwnKey_IsAccepted_UntilTheKeyIsRemoved()
{
using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256);
long first = Interlocked.Increment(ref s_seq);
long second = Interlocked.Increment(ref s_seq);
// 还没有导入:自己的密钥签的清单不被接受
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode);
using HttpResponseMessage set = await PutKeyAsync(own.ExportSubjectPublicKeyInfoPem());
Assert.Equal(HttpStatusCode.OK, set.StatusCode);
string fingerprint = Convert.ToHexStringLower(SHA256.HashData(own.ExportSubjectPublicKeyInfo()));
Assert.Equal(fingerprint, JsonNode.Parse(await set.Content.ReadAsStringAsync())!["custom"]!["fingerprint"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Created, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode);
ReleaseService releases = _backend.Services.GetRequiredService<ReleaseService>();
Assert.Equal(first, (await releases.GetBackendAsync("dev", default))!.Seq);
// 别人的密钥照样不行
using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(stranger, Manifest(second)))).StatusCode);
// 删除之后:只有它签过的清单不再被接受,已经登记的也丢掉
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key");
Assert.Equal(HttpStatusCode.OK, cleared.StatusCode);
Assert.Null(JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!["custom"]);
Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(own, Manifest(second)))).StatusCode);
Assert.Null(await releases.GetBackendAsync("dev", default));
}
[Fact]
public async Task OnlyAnAdministrator_MayChangeTheTrustedKeys()
{
using HttpClient anonymous = _backend.NewClient();
using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using HttpResponseMessage read = await anonymous.GetAsync("/api/admin/release-key");
using HttpResponseMessage write = await anonymous.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = own.ExportSubjectPublicKeyInfoPem() }));
Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode);
}
}