using System.Net; using System.Text.Json.Nodes; using MoShiro.Protocol; using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; public sealed class EnrollmentAndTaskSetTests : IClassFixture { private readonly BackendFixture _backend; public EnrollmentAndTaskSetTests(BackendFixture backend) { _backend = backend; } private async Task AgentAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + id); [Fact] public async Task NewAgent_EnrollsConnectsAndReceivesTheDefaultLineTasks() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); Assert.Equal("active", agent.EnrollmentStatus); Assert.Equal("active", agent.AuthStatus); TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); Assert.Equal(["connect", "tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray()); Assert.All(set.Tasks, t => { Assert.Equal("line", t.Scope); Assert.Equal(TaskSchedule.Continuous, t.Schedule); Assert.NotEmpty(t.Params); // 参数已经补全了默认值 }); Assert.Equal(10, set.Tasks.Single(t => t.Kind == "udp_echo").Params["rate_hz"].GetInt64()); Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_echo").Params["port"].GetInt64()); Assert.True(set.Version >= 1); JsonNode view = await AgentAsync(agent.NodeId.Value); Assert.True(view["online"]!.GetValue()); Assert.Equal("203.0.113.10", view["public_host"]!.GetValue()); Assert.Equal("0.0.1-sim", view["agent_version"]!.GetValue()); Assert.Equal(200, view["local_limits"]!["max_total_mbps"]!.GetValue()); await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["applied_task_version"]!.GetValue() == set.Version); } [Fact] public async Task TaskSet_OnlyContainsKindsTheAgentSupports() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["udp_echo"]); TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 1); Assert.Equal(["udp_echo"], set.Tasks.Select(t => t.Kind).ToArray()); } [Fact] public async Task Reconnecting_AlwaysGetsTheCurrentSnapshotAgain() { AgentSimulator first = await _backend.ConnectAgentAsync(); TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); await first.DisposeAsync(); // 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本 await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(first.NodeId.Value))["online"]!.GetValue()); Assert.NotNull(before); } [Fact] public async Task Enrollment_RejectsBadTokens_ExhaustedTokens_AndBadProofs() { using HttpResponseMessage unknown = await PostEnrollAsync(new JsonObject { ["join_token"] = "jt_nope", ["name"] = "x", ["pubkey"] = "AAAA", ["tls_spki"] = "AAAA", ["proof"] = "AAAA" }); Assert.True(unknown.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest); string token = await _backend.CreateJoinTokenAsync(maxUses: 1); await using AgentSimulator first = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("once")); InvalidOperationException reuse = await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("twice"))); Assert.Contains("401", reuse.Message); Assert.Contains("fleet_join_token_invalid", reuse.Message); // 吊销之后不能再用 string revoked = await _backend.CreateJoinTokenAsync(maxUses: 5); JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); string id = tokens["items"]!.AsArray().First(t => t!["revoked_at"] is null && t["max_uses"]!.GetValue() == 5)!["id"]!.GetValue(); using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id); Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked"))); // 吊销之后记录还在(显示「已吊销」);purge=true 才是彻底删除 tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); Assert.Contains(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); using HttpResponseMessage purge = await _backend.Admin.DeleteAsync($"/api/admin/join-tokens/{id}?purge=true"); Assert.Equal(HttpStatusCode.NoContent, purge.StatusCode); tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); Assert.DoesNotContain(tokens["items"]!.AsArray(), t => t!["id"]!.GetValue() == id); } [Fact] public async Task Enrollment_RejectsATlsPublicKeyThatDoesNotMatchItsFingerprint_AndAnUnknownRole() { string token = await _backend.CreateJoinTokenAsync(role: "agent"); using var mine = Fleet.Identity.NodeIdentity.Generate(); using var tls = Fleet.Identity.NodeIdentity.Generate(); using var impostor = Fleet.Identity.NodeIdentity.Generate(); string pubkey = Convert.ToBase64String(mine.PublicKeySpki); string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); // 指纹是 tls 的,公钥却换成了别的:Agent 将来按指纹校验证书,两者必须对得上 using HttpResponseMessage response = await PostEnrollAsync(new JsonObject { ["join_token"] = token, ["name"] = _backend.UniqueName("tls"), ["pubkey"] = pubkey, ["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(tls.PublicKeySpki)), ["tls_pubkey"] = Convert.ToBase64String(impostor.PublicKeySpki), ["proof"] = Convert.ToBase64String(mine.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey)), }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); using HttpResponseMessage badRole = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "root" })); Assert.True(badRole.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.UnprocessableEntity); } [Fact] public async Task Enrollment_RejectsAProofSignedByAnotherKey() { string token = await _backend.CreateJoinTokenAsync(); using var other = Fleet.Identity.NodeIdentity.Generate(); using var mine = Fleet.Identity.NodeIdentity.Generate(); string pubkey = Convert.ToBase64String(mine.PublicKeySpki); string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); string forged = Convert.ToBase64String(other.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey)); using HttpResponseMessage response = await PostEnrollAsync(new JsonObject { ["join_token"] = token, ["name"] = _backend.UniqueName("forged"), ["pubkey"] = pubkey, ["tls_spki"] = Convert.ToBase64String(Fleet.Identity.NodeIdentity.Fingerprint(mine.PublicKeySpki)), ["tls_pubkey"] = pubkey, ["proof"] = forged, }); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal("fleet_proof_invalid", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); // 令牌没有被消耗:用正确的证明仍然可以注册 await using AgentSimulator ok = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("honest")); Assert.Equal("active", ok.EnrollmentStatus); } [Fact] public async Task ConnectionWithAWrongSignature_IsRejected_AndLeaksNothing() { await using AgentSimulator real = await _backend.EnrollOnlyAsync(); await using AgentSimulator stranger = await _backend.EnrollOnlyAsync(); // stranger 用自己的私钥冒充 real 的节点 ID:签名对不上 AuthAttempt impostor = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => stranger.SignAuth(real.NodeId, nonce)); Assert.False(impostor.Ok); Assert.Equal("fleet_unauthorized", impostor.ErrorCode); // 节点不存在与签名错误返回同样的错误,不泄露节点是否存在 var ghost = new Fleet.Core.NodeId("agt_does_not_exist"); AuthAttempt unknown = await AgentWire.AuthenticateAsync(_backend.BaseAddress, ghost, nonce => stranger.SignAuth(ghost, nonce)); Assert.Equal(impostor.ErrorCode, unknown.ErrorCode); // 正主仍然可以连接 AuthAttempt genuine = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => real.SignAuth(real.NodeId, nonce)); Assert.True(genuine.Ok); } [Fact] public async Task PendingAgent_ConnectsButGetsNoStateAndItsChunksAreIgnored_UntilApproved() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false); Assert.Equal("pending", agent.EnrollmentStatus); Assert.Equal("pending", agent.AuthStatus); await Task.Delay(300); Assert.Empty(agent.TaskSets); RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 30_000); chunk.Header.AgentId = agent.NodeId; await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk))); await Task.Delay(300); JsonNode chunks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.NodeId.Value); Assert.Empty(chunks["items"]!.AsArray()); using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/approve", null); Assert.Equal(HttpStatusCode.OK, approve.StatusCode); TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); Assert.NotNull(set); } [Fact] public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/disable", null); Assert.Equal(HttpStatusCode.OK, disable.StatusCode); await agent.WaitUntilAsync(() => agent.Revoked); await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(agent.NodeId.Value))["online"]!.GetValue()); AuthAttempt again = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => agent.SignAuth(agent.NodeId, nonce)); Assert.False(again.Ok); Assert.Equal("fleet_node_disabled", again.ErrorCode); using HttpResponseMessage enable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/enable", null); Assert.Equal(HttpStatusCode.OK, enable.StatusCode); } [Fact] public async Task SameNameEnrollment_GoesPending_ApprovalConflicts_AndTakeoverReplacesTheKey() { string name = _backend.UniqueName("jp"); await using AgentSimulator original = await _backend.ConnectAgentAsync(name); await using AgentSimulator replacement = await _backend.EnrollOnlyAsync(name); Assert.Equal("pending", replacement.EnrollmentStatus); using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/approve", null); Assert.Equal(HttpStatusCode.Conflict, approve.StatusCode); Assert.Equal("name_conflict", JsonNode.Parse(await approve.Content.ReadAsStringAsync())!["code"]!.GetValue()); using HttpResponseMessage takeover = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/takeover", null); Assert.Equal(HttpStatusCode.OK, takeover.StatusCode); Assert.Equal(original.NodeId.Value, JsonNode.Parse(await takeover.Content.ReadAsStringAsync())!["id"]!.GetValue()); // 旧 ID 保留,历史数据不丢 using HttpResponseMessage pendingGone = await _backend.Admin.GetAsync("/api/admin/agents/" + replacement.NodeId.Value); Assert.Equal(HttpStatusCode.NotFound, pendingGone.StatusCode); await original.WaitUntilAsync(() => original.Revoked); // 旧密钥的连接被断开 } [Fact] public async Task Patch_RenamesWithConflictDetection_AndHonoursIfMatch() { await using AgentSimulator a = await _backend.ConnectAgentAsync(); await using AgentSimulator b = await _backend.ConnectAgentAsync(); JsonNode view = await AgentAsync(b.NodeId.Value); string name = view["name"]!.GetValue(); using HttpResponseMessage conflict = await _backend.Admin.PatchAsync( $"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = name }, "application/merge-patch+json")); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); using HttpResponseMessage get = await _backend.Admin.GetAsync("/api/admin/agents/" + a.NodeId.Value); string etag = get.Headers.ETag!.Tag; using var stale = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk") }, "application/merge-patch+json") }; stale.Headers.TryAddWithoutValidation("If-Match", "\"999999\""); using HttpResponseMessage precondition = await _backend.Admin.SendAsync(stale); Assert.Equal(HttpStatusCode.PreconditionFailed, precondition.StatusCode); using var fresh = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk"), ["public_visible"] = false }, "application/merge-patch+json") }; fresh.Headers.TryAddWithoutValidation("If-Match", etag); using HttpResponseMessage ok = await _backend.Admin.SendAsync(fresh); Assert.Equal(HttpStatusCode.OK, ok.StatusCode); JsonNode updated = JsonNode.Parse(await ok.Content.ReadAsStringAsync())!; Assert.Equal("hk", updated["labels"]![0]!.GetValue()); Assert.False(updated["public_visible"]!.GetValue()); Assert.NotEqual(etag, ok.Headers.ETag!.Tag); using HttpResponseMessage invalid = await _backend.Admin.PatchAsync( $"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = "bad name!" }, "application/merge-patch+json")); Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); Assert.NotNull(JsonNode.Parse(await invalid.Content.ReadAsStringAsync())!["errors"]!["name"]); } [Fact] public async Task TaskOverride_ChangesTheDesiredStateAndBumpsTheVersion() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo"); using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["agent_id"] = agent.NodeId.Value, ["base_task_id"] = udp.Id.Value, ["params"] = new JsonObject { ["rate_hz"] = 20 }, })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString()); TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20)); Assert.Equal(Defaults.TaskCount, updated.Tasks.Count); Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64()); // 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响) string overrideId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue(); using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json")); Assert.Equal(HttpStatusCode.OK, disable.StatusCode); TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo")); Assert.Equal(Defaults.TaskCount - 1, disabled.Tasks.Count); } [Fact] public async Task Tasks_ValidateParamsKindsBudgetAndTargets() { using HttpResponseMessage badParams = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 }, })); Assert.Equal(HttpStatusCode.BadRequest, badParams.StatusCode); JsonNode problem = JsonNode.Parse(await badParams.Content.ReadAsStringAsync())!; Assert.Contains("between 1 and 200", problem["errors"]!["params.rate_hz"]![0]!.GetValue()); Assert.NotNull(problem["errors"]!["params.bogus"]); using HttpResponseMessage unknownKind = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "warp_drive" })); Assert.Equal(HttpStatusCode.NotFound, unknownKind.StatusCode); Assert.Equal("unknown_kind", JsonNode.Parse(await unknownKind.Content.ReadAsStringAsync())!["code"]!.GetValue()); using HttpResponseMessage overBudget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 }, })); Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode); Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue()); using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_echo" })); Assert.Equal(HttpStatusCode.BadRequest, wrongScope.StatusCode); // 目标任务:只接受 IP(不做 DNS 解析),需要指定 Agent await using AgentSimulator agent = await _backend.ConnectAgentAsync(); using HttpResponseMessage dns = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "example.com", })); Assert.Equal(HttpStatusCode.BadRequest, dns.StatusCode); using HttpResponseMessage target = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1", })); Assert.Equal(HttpStatusCode.Created, target.StatusCode); TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target")); Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target").Target); using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["schedule"] = "every:30" })); Assert.Equal(HttpStatusCode.BadRequest, onceSchedule.StatusCode); } [Fact] public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); agent.RejectNextState = [new Fleet.Rpc.RejectedItem { Id = "tsk_x", Reason = "policy_denied" }]; using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "target", ["kind"] = "connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443", })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["rejected"]!.AsArray().Count == 1); Assert.Equal("policy_denied", (await AgentAsync(agent.NodeId.Value))["rejected"]![0]!["reason"]!.GetValue()); } [Fact] public async Task DialOnlyNode_EnrollDisableDelete_IsReflectedInTheListenersPeerList() { await using AgentSimulator listener = await _backend.ConnectAgentAsync(); await listener.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); string name = _backend.UniqueName("home"); using EnrolledDialer dialer = await _backend.CreateDialerAsync(name); string id = dialer.DialerId; Assert.StartsWith(AgentIds.Prefix, id, StringComparison.Ordinal); string peerKey = Convert.ToBase64String(dialer.Identity.PeerIdentity.PublicKeySpki); string backendKey = Convert.ToBase64String(dialer.Identity.Identity.PublicKeySpki); Assert.NotEqual(backendKey, peerKey); // 连面板的和连监听方的是两把独立的密钥 // 监听方的名单里放的是对等面的公钥,不是连面板的那把 TaskSet withDialer = await listener.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == id && p.PublicKey == peerKey)); Assert.DoesNotContain(withDialer.Peers, p => p.PublicKey == backendKey); // 重名节点进入待审批状态,尚未获得任务 string duplicateToken = await _backend.CreateJoinTokenAsync(role: "agent"); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; Fleet.Core.Result duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default); Assert.True(duplicate.IsSuccess); JsonNode pending = await AgentAsync(duplicate.Value.NodeId); Assert.Equal("pending", pending["status"]!.GetValue()); // 停用:从名单里消失,记录还在 using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{id}/disable", BackendFixture.Json(new JsonObject())); Assert.True(disabled.IsSuccessStatusCode); await listener.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != id)); // 删除 using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/agents/" + id); Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/agents/" + id); Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); } private Task PostEnrollAsync(JsonNode body) => _backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body)); }