Files
MoShiro/backend/tests/Monitor.Backend.Tests/OperationsAndSecurityTests.cs
T
2026-10-04 21:46:39 +08:00

297 lines
16 KiB
C#

using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Rpc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Monitor.Backend.Analysis;
using Monitor.Backend.Application;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
[Collection("backend")]
public sealed class OperationsAndSecurityTests
{
private const long Minute = 60_000;
private readonly BackendFixture _backend;
public OperationsAndSecurityTests(BackendFixture backend)
{
_backend = backend;
}
private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
private T Service<T>()
where T : class => _backend.Services.GetRequiredService<T>();
private T HostedService<T>()
where T : class => _backend.Services.GetServices<IHostedService>().OfType<T>().Single();
// ───────────────────────── 指标版本与回填 ─────────────────────────
[Fact]
public async Task ANewOrChangedMetric_IsBackfilledOverTheRetainedRawData()
{
long baseMs = ((Now() / Minute) - 4) * Minute;
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
for (int k = 0; k < 12; k++)
{
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000))));
}
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&step=1m&metrics=rtt.p95,loss.total";
JsonNode before = null!;
await BackendFixture.EventuallyAsync(async () =>
{
before = await _backend.GetJsonAsync(_backend.Admin, url);
return before["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200 && before["source"]!.GetValue<string>() == "1m";
});
double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>();
Assert.InRange(rtt, 60 * 0.955, 60 * 1.045);
// 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上
var store = Service<IMetricStore>();
foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default))
{
await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet<string> { "rtt" }, default);
}
await store.SetVersionAsync(new MetricVersionRecord("rtt", "stale-fingerprint", null, null), default);
JsonNode erased = await _backend.GetJsonAsync(_backend.Admin, url);
Assert.Null(erased["summary"]!["metrics"]!["rtt.p95"]);
Assert.NotNull(erased["summary"]!["metrics"]!["loss.total"]); // 其他指标不受影响
// 启动时的指纹比对发现 rtt 变了:在保留期内的原始块上重算它(限速、低优先级)
await HostedService<MetricRecompute>().RunAsync(default);
RecomputeProgress progress = HostedService<MetricRecompute>().Progress;
Assert.Equal("done", progress.State);
Assert.Equal(["rtt"], progress.Metrics.ToArray());
Assert.True(progress.WindowsTotal >= 2);
Assert.Equal(progress.WindowsTotal, progress.WindowsDone);
JsonNode after = await _backend.GetJsonAsync(_backend.Admin, url);
Assert.Equal(rtt, after["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>(), 9);
Assert.Equal(MetricRegistry.Find("rtt")!.Fingerprint, (await store.GetVersionsAsync(default)).Single(v => v.MetricId == "rtt").Fingerprint);
// 完成之后再比对:没有变化,不再重算
await HostedService<MetricRecompute>().RunAsync(default);
Assert.Equal("idle", HostedService<MetricRecompute>().Progress.State);
JsonNode api = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/analysis/recompute");
Assert.Equal("idle", api["state"]!.GetValue<string>());
}
// ───────────────────────── 保留 ─────────────────────────
[Fact]
public async Task Retention_RemovesRawChunksPastTheConfiguredPeriod_AndTheSettingTakesEffectImmediately()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
RawChunk old = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (45L * 24 * 60 * Minute));
RawChunk recent = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (2L * 24 * 60 * Minute));
RawChunk fresh = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (5 * Minute));
foreach (RawChunk chunk in new[] { old, recent, fresh })
{
Assert.True(await agent.SendChunkAsync(chunk));
}
async Task<int> CountAsync() =>
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}"))["items"]!.AsArray().Count;
Assert.Equal(3, await CountAsync());
await HostedService<RetentionService>().RunOnceAsync(default);
Assert.Equal(2, await CountAsync()); // 默认保留 30 天:45 天前的块被删除
using HttpResponseMessage patch = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 1 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
await HostedService<RetentionService>().RunOnceAsync(default);
Assert.Equal(1, await CountAsync());
using HttpResponseMessage restore = await _backend.Admin.PatchAsync(
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 30 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
}
// ───────────────────────── 节点连接的安全边界 ─────────────────────────
[Fact]
public async Task AnAuthSignatureCannotBeReplayedOnAnotherConnection()
{
await using AgentSimulator agent = await _backend.EnrollOnlyAsync();
byte[]? captured = null;
AuthAttempt first = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => captured = agent.SignAuth(agent.NodeId, nonce));
Assert.True(first.Ok);
AuthAttempt replay = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, _ => captured!);
Assert.False(replay.Ok);
Assert.Equal("fleet_unauthorized", replay.ErrorCode);
}
[Fact]
public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
try
{
await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]);
}
catch (System.Net.WebSockets.WebSocketException)
{
// 服务端可能在发送完成之前就关闭了连接
}
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue<bool>());
// 服务端仍然正常
await using AgentSimulator another = await _backend.ConnectAgentAsync();
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
}
[Fact]
public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.SendTextAsync("{not json");
await agent.SendTextAsync("[]");
await agent.SendBinaryAsync([]);
await agent.SendBinaryAsync([200, 1, 2, 3]); // 未知的二进制通道,长度不够一个完整的头部
await agent.SendBinaryAsync(OutboxFrame.Build(99, Guid.NewGuid(), [1])); // 没有注册的通道
// 节点不能调用任何“执行”类的方法:Fleet 根本没有这样的路由
foreach (string method in new[] { "fleet.exec", "fleet.shell", "monitor.exec", "monitor.write_file", "fleet.update" })
{
RpcMessage reply = await agent.RequestRawAsync(method);
Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code);
}
// 连接仍然可用
ProbeAgentsResult list = await agent.RequestProbeAgentsAsync();
Assert.Contains(list.Agents, a => a.AgentId == agent.NodeId);
}
[Fact]
public async Task WithoutHello_NoStateIsPushed_UntilTheNodeIntroducesItself()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false);
await Task.Delay(400);
Assert.Empty(agent.TaskSets);
await agent.HelloAsync();
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
}
[Fact]
public async Task PendingNodes_CannotCallProductMethods()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
RpcMessage reply = await agent.RequestRawAsync(MonitorMethods.ProbeAgents, JsonSerializer.SerializeToElement(new ProbeAgentsParams(), MonitorJsonContext.Default.ProbeAgentsParams));
Assert.Equal("fleet_node_pending", reply.Error!.Data!.Code);
// 状态与事件也不会被接受:监控数据里没有它
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
await Task.Delay(200);
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
Assert.False(view["probe_connected"]!.GetValue<bool>());
}
[Fact]
public async Task ReconnectingTheSameNode_ReplacesTheOldConnection()
{
AgentSimulator first = await _backend.ConnectAgentAsync();
await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await using AgentSimulator second = await _backend.ReconnectAsync(first);
await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
await first.WaitUntilAsync(() => first.CloseReason is not null);
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + first.NodeId.Value);
Assert.True(view["online"]!.GetValue<bool>());
}
// ───────────────────────── 用户、会话与令牌 ─────────────────────────
[Fact]
public async Task Users_CanBeCreated_PasswordChangeEndsTheirSessions_AndLogoutInvalidatesTheCookie()
{
string name = "user" + Guid.NewGuid().ToString("N")[..8];
using HttpResponseMessage weak = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "short" }));
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
Assert.NotNull(JsonNode.Parse(await weak.Content.ReadAsStringAsync())!["errors"]!["password"]);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
string userId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "another password" }));
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
using HttpClient client = _backend.NewClient();
(string cookie, string csrf) = await LoginAsync(client, name, "first password");
Assert.Equal(HttpStatusCode.OK, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
using HttpResponseMessage change = await _backend.Admin.PatchAsync($"/api/admin/users/{userId}", BackendFixture.Json(new JsonObject { ["password"] = "second password" }));
Assert.Equal(HttpStatusCode.NoContent, change.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); // 旧会话失效
using HttpResponseMessage oldPassword = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
Assert.Equal(HttpStatusCode.Unauthorized, oldPassword.StatusCode);
(cookie, csrf) = await LoginAsync(client, name, "second password");
using HttpResponseMessage logout = await Send(client, HttpMethod.Post, "/api/admin/auth/logout", cookie, csrf);
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
JsonNode users = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/users");
Assert.DoesNotContain("password", users.ToJsonString(), StringComparison.OrdinalIgnoreCase); // 响应里没有任何口令信息
}
[Fact]
public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed()
{
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" }));
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string plaintext = body["plaintext"]!.GetValue<string>();
string id = body["token"]!["id"]!.GetValue<string>();
using HttpClient client = _backend.NewClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", plaintext);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/public/catalog/kinds")).StatusCode);
string listing = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/api-tokens")).ToJsonString();
Assert.DoesNotContain(plaintext, listing, StringComparison.Ordinal);
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/api-tokens/" + id);
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/agents")).StatusCode);
}
private static async Task<(string Cookie, string Csrf)> LoginAsync(HttpClient client, string user, string password)
{
using HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password }));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (response.Headers.GetValues("Set-Cookie").Single().Split(';')[0], JsonNode.Parse(await response.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>());
}
private static Task<HttpResponseMessage> Send(HttpClient client, HttpMethod method, string url, string cookie, string? csrf = null)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Add("Cookie", cookie);
if (csrf is not null)
{
request.Headers.Add("X-CSRF-Token", csrf);
}
return client.SendAsync(request);
}
}