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

231 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
});
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;
}