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 MoShiro.Backend.Analysis; using MoShiro.Backend.Application; using MoShiro.Protocol; using MoShiro.TestSupport; namespace MoShiro.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() where T : class => _backend.Services.GetRequiredService(); private T HostedService() where T : class => _backend.Services.GetServices().OfType().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() == 1200 && before["source"]!.GetValue() == "1m"; }); double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue(); Assert.InRange(rtt, 60 * 0.955, 60 * 1.045); // 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上 var store = Service(); 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 { "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().RunAsync(default); RecomputeProgress progress = HostedService().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(), 9); Assert.Equal(MetricRegistry.Find("rtt")!.Fingerprint, (await store.GetVersionsAsync(default)).Single(v => v.MetricId == "rtt").Fingerprint); // 完成之后再比对:没有变化,不再重算 await HostedService().RunAsync(default); Assert.Equal("idle", HostedService().Progress.State); JsonNode api = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/analysis/recompute"); Assert.Equal("idle", api["state"]!.GetValue()); } // ───────────────────────── 保留 ───────────────────────── [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 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().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().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 == Defaults.TaskCount); 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()); // 服务端仍然正常 await using AgentSimulator another = await _backend.ConnectAgentAsync(); Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount)); } [Fact] public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); 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", "moshiro.exec", "moshiro.write_file", "fleet.update" }) { RpcMessage reply = await agent.RequestRawAsync(method); Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code); } // 连接仍然可用:路由器照常回复 Assert.Equal("fleet_unsupported", (await agent.RequestRawAsync("fleet.exec")).Error!.Data!.Code); } [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 == Defaults.TaskCount)); } [Fact] public async Task PendingNodes_CannotCallProductMethods() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false); // 状态与事件也不会被接受:监控数据里没有它 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()); } [Fact] public async Task ReconnectingTheSameNode_ReplacesTheOldConnection() { AgentSimulator first = await _backend.ConnectAgentAsync(); await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); await using AgentSimulator second = await _backend.ReconnectAsync(first); await second.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); // 旧连接被替换掉,但节点仍然在线(在线状态属于新连接) 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()); } // ───────────────────────── 用户、会话与令牌 ───────────────────────── [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(); 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 id = body["token"]!["id"]!.GetValue(); 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()); } private static Task 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); } }