chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,229 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Monitor.Backend.Application;
using Monitor.TestSupport;
namespace Monitor.Backend.Tests;
/// <summary>
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
/// </summary>
public class BackendFixture : IAsyncLifetime
{
public const string AdminUser = "admin";
public const string AdminPassword = "correct horse battery";
private readonly List<AgentSimulator> _agents = [];
private string _adminToken = "";
private int _counter;
/// <summary>所有测试都从同一个回环地址登录,所以共享的后端放宽限流;专门测试限流的类用 <see cref="StrictLoginFixture"/>。</summary>
protected virtual int LoginAttemptsPerMinute => 1000;
public WebApplication App { get; private set; } = null!;
public Uri BaseAddress { get; private set; } = null!;
public string DataDirectory { get; private set; } = "";
public HttpClient Anonymous { get; private set; } = null!;
/// <summary>带 admin 作用域的 API 令牌的客户端。</summary>
public HttpClient Admin { get; private set; } = null!;
public IServiceProvider Services => App.Services;
/// <summary>夹具创建的默认 Probe:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
public string DefaultProbeId { get; private set; } = "";
/// <summary>默认 Probe 与这个 Agent 组成的线路 id(<c>agent~probe</c>)。</summary>
public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}";
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
/// <summary>在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。</summary>
public async Task<JsonNode> CreateProbeAsync(string? name = null)
{
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/probes", Json(new JsonObject { ["name"] = name ?? UniqueName("probe") }));
string body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, body);
return JsonNode.Parse(body)!;
}
public async ValueTask InitializeAsync()
{
DataDirectory = Path.Combine(Path.GetTempPath(), "monitor-tests-" + Guid.NewGuid().ToString("N"));
await StartAsync();
_adminToken = await CreateAdminTokenAsync();
CreateClients();
DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue<string>();
}
public async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
foreach (AgentSimulator agent in _agents)
{
await agent.DisposeAsync();
}
Anonymous.Dispose();
Admin.Dispose();
await StopAsync();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch (IOException)
{
// 临时目录清理失败不影响测试结果
}
}
/// <summary>重启后端(保留数据目录):用来验证数据是否真的落盘。</summary>
public async Task RestartAsync()
{
Anonymous.Dispose();
Admin.Dispose();
await StopAsync();
await StartAsync();
CreateClients(); // 新的端口:HttpClient 的 BaseAddress 只能在第一次请求之前设置
}
/// <summary>用同一个身份重新连接一个已经断开的假 Agent。</summary>
public async Task<AgentSimulator> ReconnectAsync(AgentSimulator previous)
{
AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId);
agent.ProbeForChunks = new Monitor.Protocol.ProbeId(DefaultProbeId);
_agents.Add(agent);
await agent.ConnectAsync(BaseAddress);
return agent;
}
private void CreateClients()
{
Anonymous = NewClient();
Admin = NewClient();
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
}
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
public async Task<string> CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1)
{
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours }));
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
/// <summary>注册并连接一个假 Agent。</summary>
public async Task<AgentSimulator> ConnectAgentAsync(string? name = null, bool autoApprove = true, string[]? kinds = null, bool hello = true)
{
string token = await CreateJoinTokenAsync(autoApprove);
AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"), kinds);
agent.ProbeForChunks = new Monitor.Protocol.ProbeId(DefaultProbeId);
_agents.Add(agent);
await agent.ConnectAsync(BaseAddress, hello, kinds);
return agent;
}
public async Task<AgentSimulator> EnrollOnlyAsync(string? name = null, bool autoApprove = true)
{
string token = await CreateJoinTokenAsync(autoApprove);
AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"));
_agents.Add(agent);
return agent;
}
public async Task<JsonNode> GetJsonAsync(HttpClient client, string url)
{
using HttpResponseMessage response = await client.GetAsync(url);
string text = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}");
return JsonNode.Parse(text)!;
}
public static StringContent Json(JsonNode node, string mediaType = "application/json") =>
new(node.ToJsonString(), Encoding.UTF8, mediaType);
public async Task SiteMustBePublicAsync()
{
using HttpResponseMessage response = await Admin.PatchAsync("/api/admin/settings", Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json"));
response.EnsureSuccessStatusCode();
}
/// <summary>轮询直到条件成立(后台分析、状态推送都是异步的)。</summary>
public static async Task EventuallyAsync(Func<Task<bool>> condition, TimeSpan? timeout = null, string? because = null)
{
long started = TimeProvider.System.GetTimestamp();
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15);
while (TimeProvider.System.GetElapsedTime(started) < limit)
{
if (await condition())
{
return;
}
await Task.Delay(50);
}
Assert.Fail("condition was not met in time" + (because is null ? "" : ": " + because));
}
private async Task StartAsync()
{
App = BackendHost.Build(
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
options =>
{
options.DataDirectory = DataDirectory;
options.Pbkdf2Iterations = 1000;
options.SecureCookies = false;
options.WindowGrace = TimeSpan.Zero;
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
options.InitialAdminUser = AdminUser;
options.InitialAdminPassword = AdminPassword;
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
});
await App.StartAsync();
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseAddress = new Uri(address);
}
private async Task StopAsync()
{
await App.StopAsync();
await App.DisposeAsync();
}
private async Task<string> CreateAdminTokenAsync()
{
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync(
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword }));
login.EnsureSuccessStatusCode();
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) };
request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue<string>());
using HttpResponseMessage created = await session.SendAsync(request);
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
}
}
/// <summary>登录限流为每分钟 10 次(默认值)的独立后端。</summary>
public sealed class StrictLoginFixture : BackendFixture
{
protected override int LoginAttemptsPerMinute => 10;
}