using System.Net; using System.Net.Http.Headers; using System.Text.Json.Nodes; namespace MoShiro.Backend.Tests; /// 提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。 [Collection("probe-e2e")] public sealed class ElevationTests { private const string Password = "correct horse battery"; private readonly BackendFixture _backend; public ElevationTests(BackendFixture backend) { _backend = backend; } private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); private string RpId => _backend.BaseAddress.Host; /// 新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。 private async Task NewSessionAsync() { string name = _backend.UniqueName("elev"); using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); HttpClient client = _backend.NewClient(); using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); return client; } private async Task<(HttpClient, SoftwarePasskey)> StrongAsync() { (HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev"); return (client, passkey); } private async Task RegisterAsync(HttpClient session, string name = "key") { var passkey = new SoftwarePasskey(RpId, Origin); using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge))); Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); return passkey; } private async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null) { using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls })); Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!; Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue())); // 浏览器只会让用户选自己的 passkey JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue()); assertion["class"] = cls; return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); } private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue(); /// 需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。 private static Task Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody"); [Fact] public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens() { (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); using HttpClient sessionScope = session; using SoftwarePasskey passkeyScope = passkey; using HttpResponseMessage denied = await Gated(session); Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke"); Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的) Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰 // 窗口只管它那一种 using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password })); Assert.Equal("elevation_required", await ProblemCodeAsync(credential)); } [Fact] public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow() { using HttpResponseMessage allowed = await Gated(_backend.Admin); Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口 } [Fact] public async Task TheWindowCanBeListed_AndEndedEarly() { (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); using HttpClient sessionScope = session; using SoftwarePasskey passkeyScope = passkey; Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); (await ElevateAsync(session, passkey, "revoke")).Dispose(); JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray(); Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue()); Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode); Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode); } [Fact] public async Task ACredentialWindow_IsSingleUse() { (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); using HttpClient sessionScope = session; using SoftwarePasskey passkeyScope = passkey; (await ElevateAsync(session, passkey, "credential")).Dispose(); using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); Assert.Equal(HttpStatusCode.Created, first.StatusCode); using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); Assert.Equal("elevation_required", await ProblemCodeAsync(second)); } [Fact] public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow() { (HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync(); (HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync(); // 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里) using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); JsonObject foreign = theirPasskey.Assert(challenge); foreign["class"] = "enroll"; Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode); // 挑战是给 revoke 要的,提交成 enroll:不行 using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke"); Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode); // 挑战只能用一次 using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); JsonObject once = myPasskey.Assert(challenge2); once["class"] = "enroll"; Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); // 另一个用户的会话没有因此提权 Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode); } [Fact] public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow() { using HttpClient session = await NewSessionAsync(); using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权 using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null); Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰 (await ElevateAsync(session, first, "credential")).Dispose(); using SoftwarePasskey added = await RegisterAsync(session, "second"); // 删除也要窗口 (await ElevateAsync(session, first, "credential")).Dispose(); Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode); } [Fact] public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate() { (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); using HttpClient sessionScope = session; using SoftwarePasskey passkeyScope = passkey; var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") }; var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") }; Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode); using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted)); Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); (await ElevateAsync(session, passkey, "credential")).Dispose(); Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode); } [Fact] public async Task ListingUsers_NeedsOnlyTheReadCapability() { (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); using HttpClient sessionScope = session; using SoftwarePasskey passkeyScope = passkey; Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权 } }