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; /// 能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。 [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().SelectMany(s => s.Endpoints).OfType() .Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)]; Assert.NotEmpty(endpoints); foreach (RouteEndpoint endpoint in endpoints) { CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata(); 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()!.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 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()); 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()).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())]; 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() == "all"); Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetRevoke && c["trust"]!.GetValue()); } [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); } }