Files
MoShiro/backend/fleet/tests/Fleet.Tests/IdentityAndRpcTests.cs
T
justme 1ff847e45a
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: declare every protocol method's direction and enforce it in the router; add the zero-trust design [CI]
Methods, roles and binary channels are declared in one table per plane (node and peer). RpcRouter checks direction, kind and role when handlers are registered, when messages arrive (fleet_protocol_violation, disconnect after repeated violations) and before sending. NodeConnection is split by responsibility.
2026-10-07 16:17:02 +08:00

163 lines
8.1 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Tests;
public sealed class IdentityAndRpcTests
{
[Fact]
public void Signatures_VerifyOnlyForTheSameKeyPurposeAndFields()
{
using NodeIdentity node = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] signature = node.Sign(SignaturePurpose.Auth, "node-1", "nonce-1");
Assert.True(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"));
Assert.False(NodeIdentity.Verify(other.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"), "another key");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Peer, "node-1", "nonce-1"), "another purpose: a signature must not be reusable across scenarios");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-2", "nonce-1"), "another node id");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-2"), "another nonce");
}
[Fact]
public void Verify_NeverThrowsOnGarbage()
{
using NodeIdentity node = NodeIdentity.Generate();
Assert.False(NodeIdentity.Verify([1, 2, 3], [4, 5, 6], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, [], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, new byte[64], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.IsValidPublicKey([]));
Assert.False(NodeIdentity.IsValidPublicKey(new byte[91]));
Assert.True(NodeIdentity.IsValidPublicKey(node.PublicKeySpki));
}
[Fact]
public void Identity_SurvivesAnExportImportRoundTrip()
{
using NodeIdentity original = NodeIdentity.Generate();
using NodeIdentity restored = NodeIdentity.FromPkcs8(original.ExportPkcs8());
Assert.Equal(original.PublicKeySpki, restored.PublicKeySpki);
Assert.True(NodeIdentity.Verify(original.PublicKeySpki, restored.Sign("t", "a"), "t", "a"));
Assert.Equal(32, NodeIdentity.Fingerprint(original.PublicKeySpki).Length);
}
[Fact]
public void SignatureString_IsPrefixedWithPurpose()
{
Assert.Equal("fleet|v1|auth|a|b", SignatureString.Build("auth", "a", "b"));
}
[Fact]
public void OutboxFrame_RoundTrips_AndRejectsShortFrames()
{
var id = Guid.CreateVersion7();
byte[] frame = OutboxFrame.Build(16, id, [1, 2, 3]);
Assert.True(OutboxFrame.TryParse(frame, out byte channel, out Guid parsed, out ReadOnlyMemory<byte> payload));
Assert.Equal((byte)16, channel);
Assert.Equal(id, parsed);
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.False(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes - 1], out _, out _, out _));
Assert.True(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes], out _, out _, out ReadOnlyMemory<byte> empty));
Assert.Equal(0, empty.Length);
}
[Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.add"), TestTables.Req("test.boom"), TestTables.Req("test.nope")))
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage ok = (await router.DispatchAsync("alice", Request(1, "test.add", """{"a":2,"b":3}"""), CancellationToken.None))!;
Assert.Equal(5, ok.Result!.Value.Deserialize(EchoContext.Default.AddResult)!.Sum);
Assert.Equal("alice", ok.Result.Value.Deserialize(EchoContext.Default.AddResult)!.Caller);
RpcMessage denied = (await router.DispatchAsync("alice", Request(2, "test.add", """{"a":-1,"b":3}"""), CancellationToken.None))!;
Assert.Equal("fleet_forbidden", denied.Error!.Data!.Code);
Assert.Equal(RpcError.ApplicationError, denied.Error.Code);
RpcMessage invalid = (await router.DispatchAsync("alice", Request(3, "test.add", """{"a":"x"}"""), CancellationToken.None))!;
Assert.Equal(RpcError.InvalidParams, invalid.Error!.Code);
RpcMessage unknown = (await router.DispatchAsync("alice", Request(4, "test.nope", "{}"), CancellationToken.None))!;
Assert.Equal(RpcError.MethodNotFound, unknown.Error!.Code);
Assert.Equal("fleet_unsupported", unknown.Error.Data!.Code);
}
[Fact]
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{
string? logged = null;
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
Assert.Equal("fleet_internal", reply.Error!.Data!.Code);
Assert.DoesNotContain("secret", reply.Error.Message);
Assert.Equal("test.boom:secret detail", logged);
}
[Fact]
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{
int calls = 0;
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Note("test.note"), TestTables.Note("test.unknown"))).OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":1,"b":2}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":"bad"}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.unknown", "{}"), CancellationToken.None));
Assert.Equal(1, calls);
}
[Fact]
public void Envelope_OmitsAbsentFields_AndRoundTrips()
{
string request = JsonSerializer.Serialize(Request(7, "x.y", """{"k":1}"""), FleetRpcContext.Default.RpcMessage);
Assert.Equal("""{"jsonrpc":"2.0","id":7,"method":"x.y","params":{"k":1}}""", request);
RpcMessage back = JsonSerializer.Deserialize(request, FleetRpcContext.Default.RpcMessage)!;
Assert.True(back.IsRequest);
Assert.False(back.IsResponse);
Assert.True(Notification("a.b", "{}").IsNotification);
string failure = JsonSerializer.Serialize(RpcMessage.Failure(3, new FleetError(FleetErrors.Timeout, "slow")), FleetRpcContext.Default.RpcMessage);
Assert.Contains("\"code\":\"fleet_timeout\"", failure);
Assert.Equal(FleetErrors.Timeout, JsonSerializer.Deserialize(failure, FleetRpcContext.Default.RpcMessage)!.Error!.ToFleetError().Code);
}
private static RpcMessage Request(long id, string method, string json) =>
new() { Id = id, Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
private static RpcMessage Notification(string method, string json) =>
new() { Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
}
public sealed class AddParams
{
[JsonPropertyName("a")]
public int A { get; set; }
[JsonPropertyName("b")]
public int B { get; set; }
}
public sealed class AddResult
{
[JsonPropertyName("sum")]
public int Sum { get; set; }
[JsonPropertyName("caller")]
public string Caller { get; set; } = "";
}
[JsonSerializable(typeof(AddParams))]
[JsonSerializable(typeof(AddResult))]
internal sealed partial class EchoContext : JsonSerializerContext;