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)