Files
MoShiro/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
T
justme 0f8f3f9a54
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: trusted release keys setting; drop the duplicate release registration route [CI]
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.
2026-10-08 18:50:16 +08:00

125 lines
8.3 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Api;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。</summary>
[Collection("probe-e2e")]
public sealed class CapabilityTests
{
private readonly BackendFixture _backend;
public CapabilityTests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public void EveryAdminEndpoint_DeclaresACapabilityThatIsInTheCatalog()
{
RouteEndpoint[] endpoints = [.. _backend.Services.GetServices<EndpointDataSource>().SelectMany(s => s.Endpoints).OfType<RouteEndpoint>()
.Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)];
Assert.NotEmpty(endpoints);
foreach (RouteEndpoint endpoint in endpoints)
{
CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata<CapabilityRequirement>();
Assert.True(requirement is not null, $"{endpoint.RoutePattern.RawText} does not declare a capability");
if (requirement.Capability is { } capability)
{
Assert.True(capability == Capability.Authenticated || CapabilityCatalog.IsKnown(capability), $"{endpoint.RoutePattern.RawText} declares the unknown capability {capability}");
}
}
// 只有登录(密码和 passkey)是匿名的
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
}
[Fact]
public void Catalog_ExpandsWritesToReads_AndPresetsAreConsistent()
{
Assert.Equal([Capability.FleetRead, Capability.FleetWrite], CapabilityCatalog.Expand([Capability.FleetWrite]));
Assert.Contains(Capability.FleetRead, CapabilityCatalog.Expand([Capability.FleetEnroll]));
foreach (CapabilityPreset preset in CapabilityCatalog.Presets)
{
Assert.All(preset.Capabilities, id => Assert.True(CapabilityCatalog.IsKnown(id), $"{preset.Id}: {id}"));
Assert.Equal(CapabilityCatalog.Expand(preset.Capabilities).ToArray(), preset.Capabilities.Order(StringComparer.Ordinal).ToArray()); // 预设已经包含了蕴含的能力
}
Assert.All(CapabilityCatalog.All.Where(c => c.Id.EndsWith(".write", StringComparison.Ordinal)), c => Assert.Contains(c.Id[..^"write".Length] + "read", c.Implies));
}
private async Task<HttpClient> KeyAsync(params string[] capabilities)
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("cap"), ["capabilities"] = BackendFixture.Capabilities(capabilities) }));
string body = await created.Content.ReadAsStringAsync();
Assert.True(created.StatusCode == HttpStatusCode.Created, body);
HttpClient client = _backend.NewClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["plaintext"]!.GetValue<string>());
return client;
}
[Fact]
public async Task AKey_CanOnlyDoWhatItsCapabilitiesAllow_AndWriteImpliesRead()
{
using HttpClient reader = await KeyAsync(Capability.FleetRead);
using HttpClient writer = await KeyAsync(Capability.FleetWrite); // 写蕴含读
using HttpClient settings = await KeyAsync(Capability.SettingsRead);
Assert.Equal(HttpStatusCode.OK, (await reader.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await writer.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await settings.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/settings")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/raw/chunks")).StatusCode);
// 读不能写:fleet.read 的 key 不能生成加入代码,也不能创建 key
using HttpResponseMessage enroll = await reader.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
Assert.Equal(HttpStatusCode.Forbidden, enroll.StatusCode);
using HttpResponseMessage keys = await reader.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) }));
Assert.Equal(HttpStatusCode.Forbidden, keys.StatusCode);
// 没有凭证是 401,凭证不够是 403
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.GetAsync("/api/admin/agents")).StatusCode);
// /auth/me 列出实际拥有的能力(包含蕴含的)
JsonNode me = JsonNode.Parse(await (await writer.GetAsync("/api/admin/auth/me")).Content.ReadAsStringAsync())!;
Assert.Equal([Capability.FleetRead, Capability.FleetWrite], me["capabilities"]!.AsArray().Select(c => c!.GetValue<string>()).ToArray());
}
[Fact]
public async Task AKey_CannotGrantWhatItDoesNotHave_ButCanGrantASubset()
{
using HttpClient manager = await KeyAsync(Capability.KeysManage, Capability.FleetRead); // keys.manage 蕴含 keys.read
using HttpResponseMessage beyond = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead, Capability.FleetEnroll) }));
Assert.Equal(HttpStatusCode.BadRequest, beyond.StatusCode);
Assert.Contains("fleet.enroll", await beyond.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage subset = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "sub", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) }));
Assert.Equal(HttpStatusCode.Created, subset.StatusCode);
using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities("fleet.nope") }));
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
using HttpResponseMessage empty = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = new JsonArray() }));
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
}
[Fact]
public async Task AKey_HasAnExpiry_AndTheCatalogEndpointDescribesEverything()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "short", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 1 }));
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
Assert.NotNull(body["token"]!["expires_at"]);
using HttpResponseMessage bad = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 0 }));
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/capabilities");
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue<string>())];
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
}
}