330 lines
16 KiB
C#
330 lines
16 KiB
C#
using System.Net;
|
||
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;
|
||
|
||
/// <summary>首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。</summary>
|
||
internal static class Defaults
|
||
{
|
||
public const int TaskCount = 3;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
|
||
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
|
||
/// </summary>
|
||
public class BackendFixture : IAsyncLifetime
|
||
{
|
||
public const string AdminUser = "admin";
|
||
public const string AdminPassword = "correct horse battery";
|
||
|
||
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
|
||
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
|
||
|
||
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:和真实部署一样,先在管理端创建 <c>probe</c> 角色的加入令牌,再由节点自己生成密钥、用 <c>fleet-enroll</c> 同样的流程注册。
|
||
/// 返回的身份目录里有两把密钥:连面板的 <c>Identity</c> 和对等面的 <c>PeerIdentity</c>。调用方负责释放。
|
||
/// </summary>
|
||
public async Task<EnrolledProbe> 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<Fleet.Enrollment.Client.EnrollmentResult> 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));
|
||
}
|
||
|
||
/// <summary>注册一个 Probe,只要它的 id(密钥用完即释放)。</summary>
|
||
public async Task<string> 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)
|
||
{
|
||
// 临时目录清理失败不影响测试结果
|
||
}
|
||
}
|
||
|
||
/// <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 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);
|
||
}
|
||
|
||
/// <summary>新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 <see cref="PasskeyLoginAsync"/> 登录才有能力。</summary>
|
||
public async Task<(string Username, SoftwarePasskey Passkey)> CreateUserWithPasskeyAsync(string prefix = "user")
|
||
{
|
||
string name = UniqueName(prefix);
|
||
using HttpResponseMessage created = await Admin.PostAsync("/api/admin/users", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||
created.EnsureSuccessStatusCode();
|
||
using HttpClient session = NewClient();
|
||
using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||
login.EnsureSuccessStatusCode();
|
||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||
var passkey = new SoftwarePasskey(BaseAddress.Host, BaseAddress.GetLeftPart(UriPartial.Authority));
|
||
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||
options.EnsureSuccessStatusCode();
|
||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge)));
|
||
registered.EnsureSuccessStatusCode();
|
||
return (name, passkey);
|
||
}
|
||
|
||
/// <summary>用 passkey 登录(可发现凭证,不用用户名);<paramref name="returnSession"/> 为真时不设 Cookie、把会话令牌放在响应里。</summary>
|
||
public async Task<HttpResponseMessage> PasskeyLoginAsync(HttpClient client, SoftwarePasskey passkey, bool returnSession = true)
|
||
{
|
||
using HttpResponseMessage options = await client.PostAsync("/api/admin/auth/passkey/options", null);
|
||
options.EnsureSuccessStatusCode();
|
||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||
assertion["return_session"] = returnSession;
|
||
return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
|
||
}
|
||
|
||
/// <summary>有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。</summary>
|
||
public async Task<(HttpClient Client, SoftwarePasskey Passkey, string Username)> CreateStrongSessionAsync(string prefix = "strong")
|
||
{
|
||
(string name, SoftwarePasskey passkey) = await CreateUserWithPasskeyAsync(prefix);
|
||
HttpClient client = NewClient();
|
||
using HttpResponseMessage login = await PasskeyLoginAsync(client, passkey);
|
||
login.EnsureSuccessStatusCode();
|
||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||
return (client, passkey, name);
|
||
}
|
||
|
||
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, 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<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 MoShiro.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)!;
|
||
}
|
||
|
||
/// <summary>目录里的全部能力("all" 预设)。</summary>
|
||
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();
|
||
}
|
||
|
||
/// <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.ClaimCode = ClaimCode;
|
||
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
|
||
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
|
||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
||
/// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。
|
||
/// </summary>
|
||
private async Task<string> CreateAdminTokenAsync()
|
||
{
|
||
using HttpClient session = NewClient();
|
||
using HttpResponseMessage login = await session.PostAsync(
|
||
"/api/admin/auth/setup", Json(new JsonObject { ["claim_code"] = ClaimCode, ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
||
Assert.Equal(HttpStatusCode.Created, login.StatusCode);
|
||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||
|
||
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
||
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
|
||
using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
|
||
{
|
||
options.EnsureSuccessStatusCode();
|
||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("fixture", challenge)));
|
||
registered.EnsureSuccessStatusCode();
|
||
}
|
||
|
||
// passkey 登录:拿到有能力的会话(令牌走 Bearer)
|
||
using HttpClient strong = NewClient();
|
||
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/passkey/options", null))
|
||
{
|
||
options.EnsureSuccessStatusCode();
|
||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||
using HttpResponseMessage signedIn = await strong.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
|
||
signedIn.EnsureSuccessStatusCode();
|
||
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||
}
|
||
|
||
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
||
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;
|
||
}
|
||
|
||
/// <summary>注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。</summary>
|
||
public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
|
||
{
|
||
public void Dispose() => Identity.Dispose();
|
||
}
|