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; /// 首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。 internal static class Defaults { public const int TaskCount = 3; } /// /// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询), /// 并提供管理员客户端(Bearer 令牌)和假 Agent。 /// public sealed class RecordingBackendRestart : MoShiro.Backend.Application.IBackendRestart { private int _count; public int Count => Volatile.Read(ref _count); public void Request() => Interlocked.Increment(ref _count); } public class BackendFixture : IAsyncLifetime { public const string AdminUser = "admin"; public const string AdminPassword = "correct horse battery"; /// 首次认领码:和线上一样,第一个管理员通过 /api/admin/auth/setup 创建,不再用配置里的默认账户。 public const string ClaimCode = "TEST-CLAIM-CODE-0001"; 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!; /// Backend 更新确认之后「请求退出」的次数(测试里不真的退出)。 public RecordingBackendRestart Restarts { get; } = new(); public IServiceProvider Services => App.Services; /// 夹具创建的默认拨号侧 Agent:合成块()和单线路的查询都用它。 public string DefaultDialerId { get; private set; } = ""; /// 默认拨号侧 Agent 与这个 Agent 组成的线路 id(agent~dialer)。 public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultDialerId}"; public string Line(string agentId) => $"{agentId}~{DefaultDialerId}"; /// /// 注册一个新的 Agent:和真实部署一样,先在管理端创建 agent 加入令牌,再由节点自己生成密钥、用 fleet-enroll 同样的流程注册。 /// 返回的身份目录里有两把密钥:连面板的 Identity 和对等面的 PeerIdentity。调用方负责释放。 /// public async Task CreateDialerAsync(string? name = null) { string token = await CreateJoinTokenAsync(role: "agent"); string directory = Path.Combine(Path.GetTempPath(), "moshiro-dialer-id-" + Guid.NewGuid().ToString("N")); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; Fleet.Core.Result enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( http, BaseAddress, token, name ?? UniqueName("dialer"), directory, "0.0.0-test", null, TimeProvider.System, default); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); return new EnrolledDialer(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory)); } /// 注册一个拨号侧 Agent,只要它的 id(密钥用完即释放)。 public async Task CreateDialerIdAsync(string? name = null) { using EnrolledDialer dialer = await CreateDialerAsync(name); return dialer.DialerId; } public async ValueTask InitializeAsync() { DataDirectory = Path.Combine(Path.GetTempPath(), "moshiro-tests-" + Guid.NewGuid().ToString("N")); await StartAsync(); _adminToken = await CreateAdminTokenAsync(); CreateClients(); using (EnrolledDialer dialer = await CreateDialerAsync("default")) { DefaultDialerId = dialer.DialerId; } } 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.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId); _agents.Add(agent); await agent.ConnectAsync(BaseAddress); return agent; } private void CreateClients() { Anonymous = NewClient(); Admin = NewClient(); Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken); } /// 新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 登录才有能力。 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()); 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(); using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge))); registered.EnsureSuccessStatusCode(); return (name, passkey); } /// 用 passkey 登录(可发现凭证,不用用户名); 为真时不设 Cookie、把会话令牌放在响应里。 public async Task 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()); assertion["return_session"] = returnSession; return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion)); } /// 有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。 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()); return (client, passkey, name); } public HttpClient NewClient() => new() { BaseAddress = BaseAddress, Timeout = TimeSpan.FromSeconds(15) }; 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.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId); _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.ClaimCode = ClaimCode; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests) options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务 options.NodeImageRegistry = string.Empty; // 同理:后台不去真实的镜像仓库取清单 options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }, configureServices: services => { services.AddSingleton(TestReleaseKey.Instance); // 登记清单要验发布签名:测试用自己的密钥签 services.AddSingleton(Restarts); // 更新确认之后只记一笔,不真的退出 }); 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(); } /// /// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey; /// 注册之后用 passkey 登录(要签操作员集合等节点会验证的事时才需要 passkey)。 /// private async Task 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 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(); 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()); 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()); } 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(); } } /// 登录限流为每分钟 10 次(默认值)的独立后端。 public sealed class StrictLoginFixture : BackendFixture { protected override int LoginAttemptsPerMinute => 10; } /// 注册好的拨号侧 Agent:身份目录(节点自己生成的两把密钥)和加载出来的身份。 public sealed record EnrolledDialer(string DialerId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable { public void Dispose() => Identity.Dispose(); }