feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]

This commit is contained in:
justme committed 2026-10-07 22:29:24 +08:00
1 parent 1379b8c7cb
commit ff45ad086e
27 files changed
+1305 -58

No files matched your search

@@ -75,7 +75,7 @@ public sealed class ApiBasicsTests
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
using HttpResponseMessage created = await client.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
@@ -104,19 +104,19 @@ public sealed class ApiBasicsTests
using HttpResponseMessage me = await client.SendAsync(get);
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
withoutCsrf.Headers.Add("Cookie", cookie);
using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
wrongCsrf.Headers.Add("Cookie", cookie);
wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x");
using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf);
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) };
using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
good.Headers.Add("Cookie", cookie);
good.Headers.Add("X-CSRF-Token", csrf);
using HttpResponseMessage created = await client.SendAsync(good);
@@ -242,10 +242,36 @@ public class BackendFixture : IAsyncLifetime
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }) };
request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue<string>());
using HttpResponseMessage created = await session.SendAsync(request);
// 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建
string csrf = body["csrf_token"]!.GetValue<string>();
async Task<HttpResponseMessage> SendAsync(string path, JsonObject? content)
{
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) };
request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", csrf);
return await session.SendAsync(request);
}
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null))
{
options.EnsureSuccessStatusCode();
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge));
registered.EnsureSuccessStatusCode();
}
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" }))
{
options.EnsureSuccessStatusCode();
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
assertion["class"] = "credential";
using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion);
elevated.EnsureSuccessStatusCode();
}
using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() });
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
@@ -0,0 +1,190 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace MoShiro.Backend.Tests;
/// <summary>提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。</summary>
[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;
/// <summary>新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。</summary>
private async Task<HttpClient> 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<string>());
return client;
}
private async Task<SoftwarePasskey> 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<string>();
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<HttpResponseMessage> 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<string>())); // 浏览器只会让用户选自己的 passkey
JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue<string>());
assertion["class"] = cls;
return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" });
[Fact]
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode());
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll");
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰
// 窗口只管它那一种
using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody");
Assert.Equal("elevation_required", await ProblemCodeAsync(revoke));
}
[Fact]
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode());
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
[Fact]
public async Task TheWindowCanBeListed_AndEndedEarly()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
(await ElevateAsync(session, passkey, "enroll")).Dispose();
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue<string>());
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 session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
}
[Fact]
public async Task ACredentialWindow_IsSingleUse()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
(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()
{
using HttpClient mine = await NewSessionAsync();
using SoftwarePasskey myPasskey = await RegisterAsync(mine);
using HttpClient theirs = await NewSessionAsync();
using SoftwarePasskey theirPasskey = await RegisterAsync(theirs);
// 别人的 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<string>();
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<string>();
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 theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).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()
{
using HttpClient session = await NewSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") };
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()
{
using HttpClient session = await NewSessionAsync();
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
}
}
@@ -110,6 +110,27 @@ public sealed class PasskeyTests
return client;
}
private string _user = "";
/// <summary>每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。</summary>
private async Task<HttpClient> FreshSessionAsync()
{
_user = _backend.UniqueName("pk");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _user, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
return await PasswordSessionAsync(_user, "correct horse battery");
}
/// <summary>用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。</summary>
private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey)
{
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" }));
JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue<string>());
assertion["class"] = "credential";
using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion));
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
}
private static async Task<JsonNode> JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
private async Task<string> RegistrationChallengeAsync(HttpClient session)
@@ -145,7 +166,7 @@ public sealed class PasskeyTests
[Fact]
public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade()
{
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys"));
@@ -154,7 +175,7 @@ public sealed class PasskeyTests
using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync())));
JsonNode body = await JsonOf(login);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.Equal(BackendFixture.AdminUser, body["username"]!.GetValue<string>());
Assert.Equal(_user, body["username"]!.GetValue<string>());
Assert.NotNull(body["session_token"]);
Assert.Equal("no-store", login.Headers.CacheControl?.ToString());
@@ -162,14 +183,14 @@ public sealed class PasskeyTests
using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress };
signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue<string>());
JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me"));
Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue<string>());
Assert.Equal(_user, me["username"]!.GetValue<string>());
Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue<string>() == "fleet.read");
}
[Fact]
public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused()
{
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战
@@ -194,7 +215,7 @@ public sealed class PasskeyTests
[Fact]
public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne()
{
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using HttpClient session = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(session);
Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5)));
@@ -211,7 +232,7 @@ public sealed class PasskeyTests
Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode);
using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using HttpClient session = await FreshSessionAsync();
using var passkey = new SoftwarePasskey(RpId, Origin);
async Task<HttpStatusCode> TryAsync(JsonObject body)
{
@@ -230,24 +251,30 @@ public sealed class PasskeyTests
// 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册
string challenge = await RegistrationChallengeAsync(session);
Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge)));
// 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有)
await ElevateAsync(session, passkey);
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次
await ElevateAsync(session, passkey);
Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session))));
await ElevateAsync(session, passkey);
Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync())));
}
[Fact]
public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn()
{
using HttpClient admin = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword);
using HttpClient admin = await FreshSessionAsync();
using SoftwarePasskey passkey = await RegisterAsync(admin, "phone");
string other = _backend.UniqueName("user");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery");
Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行
Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == passkey.Id);
Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰
await ElevateAsync(admin, passkey);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync())));
}