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