Files
MoShiro/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs
T

408 lines
25 KiB
C#

using System.Net;
using System.Text.Json.Nodes;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class EnrollmentAndTaskSetTests
{
private readonly BackendFixture _backend;
public EnrollmentAndTaskSetTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonNode> 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 == 2);
Assert.Equal(["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<bool>());
Assert.Equal("203.0.113.10", view["public_host"]!.GetValue<string>());
Assert.Equal("0.0.1-sim", view["agent_version"]!.GetValue<string>());
Assert.Equal(200, view["local_limits"]!["max_total_mbps"]!.GetValue<double>());
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["applied_task_version"]!.GetValue<long>() == 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 == 2);
await first.DisposeAsync();
// 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本
await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(first.NodeId.Value))["online"]!.GetValue<bool>());
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<InvalidOperationException>(() => 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<int>() == 5)!["id"]!.GetValue<string>();
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id);
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<string>() == 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<string>() == id);
}
[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(new byte[32]),
["proof"] = forged,
});
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("fleet_proof_invalid", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
// 令牌没有被消耗:用正确的证明仍然可以注册
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(MonitorChannels.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 == 2);
Assert.NotNull(set);
}
[Fact]
public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
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<bool>());
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<string>());
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<string>()); // 旧 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<string>();
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<string>());
Assert.False(updated["public_visible"]!.GetValue<bool>());
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 == 2);
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(2, 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<string>();
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.Single(disabled.Tasks);
}
[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<string>());
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<string>());
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<string>());
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 == 2);
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<string>());
}
[Fact]
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.Equal("no-store", created.Headers.CacheControl!.ToString());
JsonNode identity = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string id = identity["probe_id"]!.GetValue<string>();
using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue<string>()));
Assert.Contains(identity["bootstrap"]!.AsArray(), b => b!["agent_id"]!.GetValue<string>() == agent.NodeId.Value);
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key1.PublicKeySpki)));
// The panel lists it; two probes can coexist and a duplicate name is refused
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue<string>();
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = name }));
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
// Rename
using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" }));
Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>());
// Rotate: same id, new key; the old key leaves every agent's list
using HttpResponseMessage rotated = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
JsonNode regenerated = JsonNode.Parse(await rotated.Content.ReadAsStringAsync())!;
Assert.Equal(id, regenerated["probe_id"]!.GetValue<string>());
Assert.NotEqual(identity["private_key"]!.GetValue<string>(), regenerated["private_key"]!.GetValue<string>());
using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue<string>()));
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key2.PublicKeySpki)));
// Revoke: gone from the peer list, kept in the panel as revoked; it cannot be rotated any more
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null);
Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>());
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id));
using HttpResponseMessage rotateRevoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
Assert.Equal(HttpStatusCode.Conflict, rotateRevoked.StatusCode);
// Delete
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id);
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
}
[Fact]
public async Task ProbeAgentList_IsServedToAgents_AndPushedWhenTheSetOfAgentsChanges()
{
await using AgentSimulator first = await _backend.ConnectAgentAsync();
ProbeAgentsResult list = await first.RequestProbeAgentsAsync();
Assert.Contains(list.Agents, a => a.AgentId == first.NodeId && a.Host == "203.0.113.10" && a.TcpPort == 7443);
Assert.All(list.Agents, a => Assert.Equal(32, Convert.FromBase64String(a.Spki).Length));
await using AgentSimulator second = await _backend.ConnectAgentAsync();
ProbeAgentsResult pushed = await WaitForAsync(first, l => l.Agents.Any(a => a.AgentId == second.NodeId));
Assert.True(pushed.Version > list.Version);
using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{second.NodeId.Value}/disable", null);
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
await WaitForAsync(first, l => l.Agents.All(a => a.AgentId != second.NodeId));
}
private static async Task<ProbeAgentsResult> WaitForAsync(AgentSimulator agent, Func<ProbeAgentsResult, bool> predicate)
{
ProbeAgentsResult? found = null;
await BackendFixture.EventuallyAsync(() =>
{
found = agent.ProbeAgentLists.ToArray().LastOrDefault(predicate);
return Task.FromResult(found is not null);
});
return found!;
}
private Task<HttpResponseMessage> PostEnrollAsync(JsonNode body) =>
_backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body));
}