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 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 empty)); Assert.Equal(0, empty.Length); } [Fact] public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors() { var router = new RpcRouter(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.Fail(FleetErrors.Forbidden, "negative") : Result.Ok(new AddResult { Sum = p.A + p.B, Caller = caller }))) .On("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(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message) .On("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(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;