diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs index ce0c71f..f8e2024 100644 --- a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs @@ -55,42 +55,12 @@ public sealed class OwnerEndToEndTests : IDisposable public void Dispose() => _options.RequireOwner = _before; - private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); - - /// 建立信任根:一个有 passkey 的操作员给操作员集合签名。 - private async Task<(HttpClient Session, SoftwarePasskey Passkey)> EstablishTrustAsync() - { - (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("owner"); - JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; - if (!state["initialized"]!.GetValue()) - { - string statement = state["proposal"]!["statement"]!.GetValue(); - JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); - var body = new JsonObject - { - ["statement"] = statement, - ["credential_id"] = assertion["credential_id"]!.DeepClone(), - ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), - ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), - ["signature"] = assertion["signature"]!.DeepClone(), - }; - using HttpResponseMessage committed = await session.PostAsync("/api/admin/trust", BackendFixture.Json(body)); - Assert.Equal(HttpStatusCode.Created, committed.StatusCode); - } - - return (session, passkey); - } - - private async Task OperatorsAsync(HttpClient session) => - JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!["operators"]!; - [Fact] public async Task ANodeCannotBeAddedWithoutAnOwner() { - (HttpClient session, SoftwarePasskey passkey) = await EstablishTrustAsync(); - using HttpClient sessionScope = session; - using SoftwarePasskey passkeyScope = passkey; - string known = (await OperatorsAsync(session))[0]!.GetValue(); + Operator op = await SharedOperator.GetAsync(_backend); // 共享的操作员:不要释放它 + HttpClient session = op.Session; + string known = op.Passkey.Id; using HttpResponseMessage none = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray() })); Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); diff --git a/backend/tests/MoShiro.Backend.Tests/SetupTests.cs b/backend/tests/MoShiro.Backend.Tests/SetupTests.cs index 3c13771..22e87d4 100644 --- a/backend/tests/MoShiro.Backend.Tests/SetupTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/SetupTests.cs @@ -44,7 +44,20 @@ public sealed class SetupTests : IAsyncLifetime await _app.StopAsync(); await _app.DisposeAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件 - Directory.Delete(_directory, recursive: true); + TryDelete(_directory); + } + + /// 临时目录:尽力删除。SQLite 的 WAL 临时文件可能还没释放,留下一个临时目录比让测试失败好。 + private static void TryDelete(string directory) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // 系统临时目录里的残留,会被清理 + } } private Task SetupAsync(string? code, string username, string password, bool returnSession = false) => @@ -143,7 +156,7 @@ public sealed class SetupTests : IAsyncLifetime await generated.StopAsync(); await generated.DisposeAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); - Directory.Delete(directory, recursive: true); + TryDelete(directory); } } } diff --git a/backend/tests/MoShiro.Backend.Tests/SharedOperator.cs b/backend/tests/MoShiro.Backend.Tests/SharedOperator.cs new file mode 100644 index 0000000..050488f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/SharedOperator.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Identity; + +namespace MoShiro.Backend.Tests; + +/// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 +internal sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); + +/// +/// 共享的后端上只有一个信任根(创世只能签一次):谁先需要,谁来建立,其余的测试用同一个操作员。 +/// 信任相关的测试都从这里拿,不要各自去建立信任根,否则测试的先后顺序会决定谁失败。 +/// +internal static class SharedOperator +{ + private static readonly ConditionalWeakTable s_operators = []; + private static readonly SemaphoreSlim s_gate = new(1, 1); + + public static async Task GetAsync(BackendFixture backend) + { + await s_gate.WaitAsync(); + try + { + if (s_operators.TryGetValue(backend, out Operator? existing)) + { + return existing; + } + + // 操作员:有 passkey、用 passkey 登录的会话 + (HttpClient session, SoftwarePasskey passkey, _) = await backend.CreateStrongSessionAsync("operator"); + + // 创世:用 passkey 对集合本身签名(节点据此验链) + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["initialized"]!.GetValue()); + string statement = state["proposal"]!["statement"]!.GetValue(); + Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); + using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); + Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); + string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); + Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); + + var made = new Operator(session, passkey, fingerprint); + s_operators.Add(backend, made); + return made; + } + finally + { + s_gate.Release(); + } + } + + public static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 + public static JsonObject Sign(SoftwarePasskey passkey, string statement) + { + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + return new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 1d56db5..71a81d0 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -20,9 +20,6 @@ namespace MoShiro.Backend.Tests; [Collection("probe-e2e")] public sealed class TrustTests { - private static readonly ConditionalWeakTable s_operators = []; - private static readonly SemaphoreSlim s_gate = new(1, 1); - private readonly BackendFixture _backend; public TrustTests(BackendFixture backend) @@ -30,57 +27,11 @@ public sealed class TrustTests _backend = backend; } - /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 - private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); + private Task OperatorAsync() => SharedOperator.GetAsync(_backend); - private async Task OperatorAsync() - { - await s_gate.WaitAsync(); - try - { - if (s_operators.TryGetValue(_backend, out Operator? existing)) - { - return existing; - } + private static string Base64UrlOf(string statement) => SharedOperator.Base64UrlOf(statement); - // 操作员:有 passkey、用 passkey 登录的会话 - (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator"); - - // 创世:用 passkey 对集合本身签名(节点据此验链) - JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; - Assert.False(state["initialized"]!.GetValue()); - string statement = state["proposal"]!["statement"]!.GetValue(); - Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); - using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); - Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); - string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); - Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); - - var made = new Operator(session, passkey, fingerprint); - s_operators.Add(_backend, made); - return made; - } - finally - { - s_gate.Release(); - } - } - - private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); - - /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 - private static JsonObject Sign(SoftwarePasskey passkey, string statement) - { - JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); - return new JsonObject - { - ["statement"] = statement, - ["credential_id"] = assertion["credential_id"]!.DeepClone(), - ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), - ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), - ["signature"] = assertion["signature"]!.DeepClone(), - }; - } + private static JsonObject Sign(SoftwarePasskey passkey, string statement) => SharedOperator.Sign(passkey, statement); /// 创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。 private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)