A probe now links to the backend like an agent (probe.json carries the server address). The backend sends the agent list as desired state and the probe reports its platform and build in its own hello. The agent no longer relays the list or the probe platform. Existing probes must be re-created.
380 lines
23 KiB
C#
380 lines
23 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.True(Uri.TryCreate(identity["server"]!.GetValue<string>(), UriKind.Absolute, out _)); // Probe 直接连面板:probe.json 里是面板的地址
|
|
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);
|
|
}
|
|
|
|
private Task<HttpResponseMessage> PostEnrollAsync(JsonNode body) =>
|
|
_backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body));
|
|
}
|