Files
MoShiro/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs
T
justme a2f88a344b
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00

133 lines
8.6 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"], 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>());
}
[Fact]
public async Task ReleaseKey_CanOnlyPublishReleases()
{
using HttpClient ci = await KeyAsync(Capability.ReleasePublish);
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode);
}
}