231 lines
9.6 KiB
C#
231 lines
9.6 KiB
C#
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;
|
||
}
|