Files
MoShiro/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs
T
justme 636a784043
ci/woodpecker/push/main-amd64-build Pipeline is running
fix: the panel polls the registry for its own and the nodes' releases [CI]
Registering a manifest with the panel is best effort in CI (an unreachable
panel only warns, the pipeline stays green). The Backend update notice then
never appeared, because the host updater only reads the mailbox the panel
writes. A background poller now fetches the signed manifests of the agent, the
probe and the backend itself from the registry every five minutes, verifies the
release signature and registers them, writing the backend one into the host
updater's mailbox.
2026-10-08 19:09:21 +08:00

332 lines
17 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;
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.NodeImageRegistry = string.Empty; // 同理:后台不去真实的镜像仓库取清单
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
},
configureServices: services => services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签
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();
}