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 MoShiro.Backend.Application;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// 首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。
internal static class Defaults
{
public const int TaskCount = 3;
}
///
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
///
public class BackendFixture : IAsyncLifetime
{
public const string AdminUser = "admin";
public const string AdminPassword = "correct horse battery";
private readonly List _agents = [];
private string _adminToken = "";
private int _counter;
/// 所有测试都从同一个回环地址登录,所以共享的后端放宽限流;专门测试限流的类用 。
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!;
/// 带 admin 作用域的 API 令牌的客户端。
public HttpClient Admin { get; private set; } = null!;
public IServiceProvider Services => App.Services;
/// 夹具创建的默认 Probe:合成块()和单线路的查询都用它。
public string DefaultProbeId { get; private set; } = "";
/// 默认 Probe 与这个 Agent 组成的线路 id(agent~probe)。
public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}";
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
///
/// 注册一个新的 Probe:和真实部署一样,先在管理端创建 probe 角色的加入令牌,再由节点自己生成密钥、用 fleet-enroll 同样的流程注册。
/// 返回的身份目录里有两把密钥:连面板的 Identity 和对等面的 PeerIdentity。调用方负责释放。
///
public async Task CreateProbeAsync(string? name = null)
{
string token = await CreateJoinTokenAsync(role: "probe");
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N"));
using var http = new HttpClient();
Fleet.Core.Result enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default);
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
return new EnrolledProbe(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory));
}
/// 注册一个 Probe,只要它的 id(密钥用完即释放)。
public async Task CreateProbeIdAsync(string? name = null)
{
using EnrolledProbe probe = await CreateProbeAsync(name);
return probe.ProbeId;
}
public async ValueTask InitializeAsync()
{
DataDirectory = Path.Combine(Path.GetTempPath(), "moshiro-tests-" + Guid.NewGuid().ToString("N"));
await StartAsync();
_adminToken = await CreateAdminTokenAsync();
CreateClients();
using (EnrolledProbe probe = await CreateProbeAsync("default"))
{
DefaultProbeId = probe.ProbeId;
}
}
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)
{
// 临时目录清理失败不影响测试结果
}
}
/// 重启后端(保留数据目录):用来验证数据是否真的落盘。
public async Task RestartAsync()
{
Anonymous.Dispose();
Admin.Dispose();
await StopAsync();
await StartAsync();
CreateClients(); // 新的端口:HttpClient 的 BaseAddress 只能在第一次请求之前设置
}
/// 用同一个身份重新连接一个已经断开的假 Agent。
public async Task ReconnectAsync(AgentSimulator previous)
{
AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId);
agent.ProbeForChunks = new MoShiro.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 CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1, string role = "agent")
{
using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["role"] = role, ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours }));
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue();
}
/// 注册并连接一个假 Agent。
public async Task 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 MoShiro.Protocol.ProbeId(DefaultProbeId);
_agents.Add(agent);
await agent.ConnectAsync(BaseAddress, hello, kinds);
return agent;
}
public async Task 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 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)!;
}
/// 目录里的全部能力("all" 预设)。
public static JsonArray AllCapabilities() => Capabilities(CapabilityCatalog.Presets.Single(p => p.Id == "all").Capabilities.ToArray());
public static JsonArray Capabilities(params string[] ids) => [.. ids.Select(id => (JsonNode?)JsonValue.Create(id))];
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();
}
/// 轮询直到条件成立(后台分析、状态推送都是异步的)。
public static async Task EventuallyAsync(Func> 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.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
});
await App.StartAsync();
string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First();
BaseAddress = new Uri(address);
}
private async Task StopAsync()
{
await App.StopAsync();
await App.DisposeAsync();
}
private async Task 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", ["capabilities"] = AllCapabilities() }) };
request.Headers.Add("Cookie", cookie);
request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue());
using HttpResponseMessage created = await session.SendAsync(request);
created.EnsureSuccessStatusCode();
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue();
}
}
/// 登录限流为每分钟 10 次(默认值)的独立后端。
public sealed class StrictLoginFixture : BackendFixture
{
protected override int LoginAttemptsPerMinute => 10;
}
/// 注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。
public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
{
public void Dispose() => Identity.Dispose();
}