163 lines
7.8 KiB
C#
163 lines
7.8 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>()
|
|
.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>((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>().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;
|