ci/woodpecker/push/main-amd64-build Pipeline was successful
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.
163 lines
8.1 KiB
C#
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;
|