From a498767c2aaf07ca1adddb202581c4b68d5d2444 Mon Sep 17 00:00:00 2001 From: mtmk Date: Thu, 22 Aug 2024 11:55:49 +0100 Subject: [PATCH] Fixed export type issues (#9) * Fixed export type issues * Test coverage * Test coverage --- .../Models/NatsAccountClaimsTests.cs | 4 +- NATS.Jwt.Tests/Models/NatsExportTests.cs | 40 ++++++++++- NATS.Jwt.Tests/Models/NatsImportTests.cs | 4 +- NATS.Jwt.Tests/NatsJwtTests.cs | 69 ++++++++++++++++++- NATS.Jwt/Internal/NatsExportComparer.cs | 18 +++++ NATS.Jwt/Internal/NatsImportComparer.cs | 18 +++++ .../Internal/NatsJsonStringEnumConverter.cs | 62 +++++++++++++++++ NATS.Jwt/Models/NatsExport.cs | 4 +- NATS.Jwt/Models/NatsExportType.cs | 25 +++++++ NATS.Jwt/Models/NatsImport.cs | 4 +- NATS.Jwt/NatsJwt.cs | 8 ++- NATS.Jwt/PublicAPI.Unshipped.txt | 8 ++- 12 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 NATS.Jwt/Internal/NatsExportComparer.cs create mode 100644 NATS.Jwt/Internal/NatsImportComparer.cs create mode 100644 NATS.Jwt/Internal/NatsJsonStringEnumConverter.cs create mode 100644 NATS.Jwt/Models/NatsExportType.cs diff --git a/NATS.Jwt.Tests/Models/NatsAccountClaimsTests.cs b/NATS.Jwt.Tests/Models/NatsAccountClaimsTests.cs index 8430480..89e53df 100644 --- a/NATS.Jwt.Tests/Models/NatsAccountClaimsTests.cs +++ b/NATS.Jwt.Tests/Models/NatsAccountClaimsTests.cs @@ -64,7 +64,7 @@ public void SerializeDeserialize_FullNatsAccountClaims_ShouldSucceed() Subject = "import.>", Account = "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII", LocalSubject = "local.import.>", - Type = 1, + Type = NatsExportType.Service, Share = true, AllowTrace = true, }, @@ -75,7 +75,7 @@ public void SerializeDeserialize_FullNatsAccountClaims_ShouldSucceed() { Name = "TestExport", Subject = "export.>", - Type = 1, + Type = NatsExportType.Service, TokenReq = true, Revocations = new Dictionary diff --git a/NATS.Jwt.Tests/Models/NatsExportTests.cs b/NATS.Jwt.Tests/Models/NatsExportTests.cs index 3ab7899..042c1da 100644 --- a/NATS.Jwt.Tests/Models/NatsExportTests.cs +++ b/NATS.Jwt.Tests/Models/NatsExportTests.cs @@ -17,7 +17,7 @@ public void TestNatsExportSerializationDeserialization() { Name = "TestExport", Subject = "test.subject", - Type = 1, + Type = NatsExportType.Service, TokenReq = true, Revocations = new() { { "key1", 123456789L }, { "key2", 987654321L }, }, ResponseType = "Stream", @@ -32,7 +32,7 @@ public void TestNatsExportSerializationDeserialization() string json = JsonSerializer.Serialize(natsExport); - string expectedJson = "{\"name\":\"TestExport\",\"subject\":\"test.subject\",\"type\":1,\"token_req\":true,\"revocations\":{\"key1\":123456789,\"key2\":987654321},\"response_type\":\"Stream\",\"response_threshold\":\"00:00:05\",\"service_latency\":{\"sampling\":50,\"results\":\"results.subject\"},\"account_token_position\":2,\"advertise\":true,\"allow_trace\":true,\"description\":\"Test Description\",\"info_url\":\"https://example.com/info\"}"; + string expectedJson = "{\"name\":\"TestExport\",\"subject\":\"test.subject\",\"type\":\"service\",\"token_req\":true,\"revocations\":{\"key1\":123456789,\"key2\":987654321},\"response_type\":\"Stream\",\"response_threshold\":\"00:00:05\",\"service_latency\":{\"sampling\":50,\"results\":\"results.subject\"},\"account_token_position\":2,\"advertise\":true,\"allow_trace\":true,\"description\":\"Test Description\",\"info_url\":\"https://example.com/info\"}"; Assert.Equal(expectedJson, json); @@ -54,4 +54,40 @@ public void TestNatsExportSerializationDeserialization() Assert.Equal(natsExport.Description, deserializedNatsExport.Description); Assert.Equal(natsExport.InfoUrl, deserializedNatsExport.InfoUrl); } + + [Theory] + [InlineData(NatsExportType.Unknown, "unknown")] + [InlineData(NatsExportType.Stream, "stream")] + [InlineData(NatsExportType.Service, "service")] + public void TestExportTypeSerializationDeserialization(NatsExportType type, string jsonString) + { + var export = new NatsExport { Type = type }; + + string json = JsonSerializer.Serialize(export); + + string expectedJson = type == NatsExportType.Unknown ? "{}" : $"{{\"type\":\"{jsonString}\"}}"; + + Assert.Equal(expectedJson, json); + + var deserializedNatsExportType = JsonSerializer.Deserialize(json); + + Assert.Equal(type, deserializedNatsExportType.Type); + } + + [Fact] + public void TestExportTypeExceptionsInSerializationDeserialization() + { + var export = new NatsExport { Type = (NatsExportType)42 }; + Assert.Throws(() => JsonSerializer.Serialize(export)); + + string json = "{\"type\":\"not-a-valid-value\"}"; + Assert.Throws(() => JsonSerializer.Deserialize(json)); + + string json2 = "{\"type\":\"unknown\"}"; + var deserializedNatsExportType = JsonSerializer.Deserialize(json2); + Assert.Equal(NatsExportType.Unknown, deserializedNatsExportType.Type); + + string json3 = "{\"type\":1}"; + Assert.Throws(() => JsonSerializer.Deserialize(json3)); + } } diff --git a/NATS.Jwt.Tests/Models/NatsImportTests.cs b/NATS.Jwt.Tests/Models/NatsImportTests.cs index 928a4fa..c43001a 100644 --- a/NATS.Jwt.Tests/Models/NatsImportTests.cs +++ b/NATS.Jwt.Tests/Models/NatsImportTests.cs @@ -19,7 +19,7 @@ public void TestNatsImportSerializationDeserialization() Account = "ACC123", Token = "TOKEN456", LocalSubject = "local.subject", - Type = 1, + Type = NatsExportType.Service, Share = true, AllowTrace = true, To = "to.subject", @@ -27,7 +27,7 @@ public void TestNatsImportSerializationDeserialization() string json = JsonSerializer.Serialize(natsImport); - string expectedJson = "{\"name\":\"TestImport\",\"subject\":\"test.subject\",\"account\":\"ACC123\",\"token\":\"TOKEN456\",\"to\":\"to.subject\",\"local_subject\":\"local.subject\",\"type\":1,\"share\":true,\"allow_trace\":true}"; + string expectedJson = "{\"name\":\"TestImport\",\"subject\":\"test.subject\",\"account\":\"ACC123\",\"token\":\"TOKEN456\",\"to\":\"to.subject\",\"local_subject\":\"local.subject\",\"type\":\"service\",\"share\":true,\"allow_trace\":true}"; Assert.Equal(expectedJson, json); diff --git a/NATS.Jwt.Tests/NatsJwtTests.cs b/NATS.Jwt.Tests/NatsJwtTests.cs index 08850f7..682c3e0 100644 --- a/NATS.Jwt.Tests/NatsJwtTests.cs +++ b/NATS.Jwt.Tests/NatsJwtTests.cs @@ -1,11 +1,13 @@ using System.Text.Json; +using System.Text.Json.Nodes; using NATS.Jwt.Models; using NATS.NKeys; using Xunit; +using Xunit.Abstractions; namespace NATS.Jwt.Tests; -public class NatsJwtTests +public class NatsJwtTests(ITestOutputHelper output) { private readonly NatsJwt _natsJwt = new(); @@ -269,4 +271,69 @@ public void TestNewAccountClaims() Assert.Equal(subject, claims.Subject); Assert.NotNull(claims.Account); } + + [Fact] + public void TestMultipleExports() + { + var jwtUtils = new NatsJwt(); + + var operatorSigningKey = KeyPair.CreatePair(PrefixByte.Operator); + var systemAccountKeyPair = KeyPair.CreatePair(PrefixByte.Account); + + // Create System Account + var systemAccountClaims = jwtUtils.NewAccountClaims(systemAccountKeyPair.GetPublicKey()); + systemAccountClaims.Name = "SYS"; + systemAccountClaims.Account.Exports = + [ + new() + { + Name = "account-monitoring-services", + Subject = "$SYS.REQ.ACCOUNT.*.*", + AccountTokenPosition = 4, + Type = NatsExportType.Service, + ResponseType = "Stream", + Description = "Request account specific monitoring services for: SUBSZ, CONNZ, LEAFZ, JSZ and INFO", + InfoUrl = "https://docs.nats.io/nats-server/configuration/sys_accounts", + }, + new() + { + Name = "account-monitoring-streams", + Subject = "$SYS.ACCOUNT.*.>", + AccountTokenPosition = 3, + Type = NatsExportType.Service, + Description = "Account specific monitoring stream", + InfoUrl = "https://docs.nats.io/nats-server/configuration/sys_accounts", + }, + ]; + systemAccountClaims.Account.Imports = + [ + new NatsImport + { + Name = "account-monitoring", + Subject = "$SYS.ACCOUNT.*.*", + Account = systemAccountKeyPair.GetPublicKey(), + Type = NatsExportType.Service, + LocalSubject = "account-monitoring", + }, + new NatsImport + { + Name = "account-monitoring2", + Subject = "$SYS.ACCOUNT.*.>", + Account = systemAccountKeyPair.GetPublicKey(), + Type = NatsExportType.Service, + LocalSubject = "account-monitoring2", + }, + ]; + + var jwt = jwtUtils.EncodeAccountClaims(systemAccountClaims, operatorSigningKey); + var payload = EncodingUtils.FromBase64UrlEncoded(jwt.Split('.')[1]); + var json = JsonSerializer.Deserialize(payload); + + // Verify the exports are sorted by name + Assert.Equal("account-monitoring-streams", json["nats"]["exports"][0]["name"].GetValue()); + Assert.Equal("account-monitoring-services", json["nats"]["exports"][1]["name"].GetValue()); + + string jsonStr = JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true }); + output.WriteLine(jsonStr); + } } diff --git a/NATS.Jwt/Internal/NatsExportComparer.cs b/NATS.Jwt/Internal/NatsExportComparer.cs new file mode 100644 index 0000000..fde3777 --- /dev/null +++ b/NATS.Jwt/Internal/NatsExportComparer.cs @@ -0,0 +1,18 @@ +// Copyright (c) The NATS Authors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using NATS.Jwt.Models; + +namespace NATS.Jwt.Internal; + +/// +internal class NatsExportComparer : IComparer +{ + /// + public int Compare(NatsExport? x, NatsExport? y) + { + return string.Compare(x?.Subject, y?.Subject, StringComparison.Ordinal); + } +} diff --git a/NATS.Jwt/Internal/NatsImportComparer.cs b/NATS.Jwt/Internal/NatsImportComparer.cs new file mode 100644 index 0000000..217ec57 --- /dev/null +++ b/NATS.Jwt/Internal/NatsImportComparer.cs @@ -0,0 +1,18 @@ +// Copyright (c) The NATS Authors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using NATS.Jwt.Models; + +namespace NATS.Jwt.Internal; + +/// +internal class NatsImportComparer : IComparer +{ + /// + public int Compare(NatsImport? x, NatsImport? y) + { + return string.Compare(x?.Subject, y?.Subject, StringComparison.Ordinal); + } +} diff --git a/NATS.Jwt/Internal/NatsJsonStringEnumConverter.cs b/NATS.Jwt/Internal/NatsJsonStringEnumConverter.cs new file mode 100644 index 0000000..ccc0082 --- /dev/null +++ b/NATS.Jwt/Internal/NatsJsonStringEnumConverter.cs @@ -0,0 +1,62 @@ +// Copyright (c) The NATS Authors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using NATS.Jwt.Models; + +namespace NATS.Jwt.Internal; + +/// +internal class NatsJsonStringEnumConverter : JsonConverter + where TEnum : struct, Enum +{ + /// + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new InvalidOperationException(); + } + + var stringValue = reader.GetString(); + + if (typeToConvert == typeof(NatsExportType)) + { + switch (stringValue) + { + case "unknown": + return (TEnum)(object)NatsExportType.Unknown; + case "stream": + return (TEnum)(object)NatsExportType.Stream; + case "service": + return (TEnum)(object)NatsExportType.Service; + } + } + + throw new InvalidOperationException($"Reading unknown enum type {typeToConvert.Name} or value {stringValue}"); + } + + /// + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + if (value is NatsExportType consumerConfigDeliverPolicy) + { + switch (consumerConfigDeliverPolicy) + { + case NatsExportType.Unknown: + writer.WriteStringValue("unknown"); + return; + case NatsExportType.Stream: + writer.WriteStringValue("stream"); + return; + case NatsExportType.Service: + writer.WriteStringValue("service"); + return; + } + } + + throw new InvalidOperationException($"Writing unknown enum value {value.GetType().Name}.{value}"); + } +} diff --git a/NATS.Jwt/Models/NatsExport.cs b/NATS.Jwt/Models/NatsExport.cs index 0b3199b..fa2a8d9 100644 --- a/NATS.Jwt/Models/NatsExport.cs +++ b/NATS.Jwt/Models/NatsExport.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using NATS.Jwt.Internal; namespace NATS.Jwt.Models; @@ -31,7 +32,8 @@ public record NatsExport /// [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public int Type { get; set; } + [JsonConverter(typeof(NatsJsonStringEnumConverter))] + public NatsExportType Type { get; set; } /// /// Gets or sets a value indicating whether a token is required. diff --git a/NATS.Jwt/Models/NatsExportType.cs b/NATS.Jwt/Models/NatsExportType.cs new file mode 100644 index 0000000..82303f4 --- /dev/null +++ b/NATS.Jwt/Models/NatsExportType.cs @@ -0,0 +1,25 @@ +// Copyright (c) The NATS Authors. +// Licensed under the Apache License, Version 2.0. + +namespace NATS.Jwt.Models; + +/// +/// Defines the type of export. +/// +public enum NatsExportType +{ + /// + /// Unknown is used if we don't know the type. + /// + Unknown = 0, + + /// + /// Stream defines the type field value for a stream "stream". + /// + Stream = 1, + + /// + /// Service defines the type field value for a service "service". + /// + Service = 2, +} diff --git a/NATS.Jwt/Models/NatsImport.cs b/NATS.Jwt/Models/NatsImport.cs index 1d8b4d3..020cb48 100644 --- a/NATS.Jwt/Models/NatsImport.cs +++ b/NATS.Jwt/Models/NatsImport.cs @@ -3,6 +3,7 @@ using System; using System.Text.Json.Serialization; +using NATS.Jwt.Internal; namespace NATS.Jwt.Models; @@ -70,7 +71,8 @@ public record NatsImport /// [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public int Type { get; set; } + [JsonConverter(typeof(NatsJsonStringEnumConverter))] + public NatsExportType Type { get; set; } /// /// Gets or sets a value indicating whether the import is shared. diff --git a/NATS.Jwt/NatsJwt.cs b/NATS.Jwt/NatsJwt.cs index 51754d2..b63b7b9 100644 --- a/NATS.Jwt/NatsJwt.cs +++ b/NATS.Jwt/NatsJwt.cs @@ -84,6 +84,10 @@ public class NatsJwt /// public const string AlgorithmNkey = "ed25519-nkey"; + private static readonly NatsExportComparer ExportComparer = new(); + + private static readonly NatsImportComparer ImportComparer = new(); + /// /// Formats the user configuration. /// @@ -263,8 +267,8 @@ public string EncodeUserClaims(NatsUserClaims userClaims, KeyPair keyPair, DateT public string EncodeAccountClaims(NatsAccountClaims accountClaims, KeyPair keyPair, DateTimeOffset? issuedAt = null) { SetVersion(accountClaims.Account, AccountClaim); - accountClaims.Account.Imports?.Sort(); - accountClaims.Account.Exports?.Sort(); + accountClaims.Account.Imports?.Sort(ImportComparer); + accountClaims.Account.Exports?.Sort(ExportComparer); return DoEncode(NatsJwtHeader, keyPair, accountClaims, JsonContext.Default.NatsAccountClaims, issuedAt); } diff --git a/NATS.Jwt/PublicAPI.Unshipped.txt b/NATS.Jwt/PublicAPI.Unshipped.txt index 96dfc63..2b29e58 100644 --- a/NATS.Jwt/PublicAPI.Unshipped.txt +++ b/NATS.Jwt/PublicAPI.Unshipped.txt @@ -188,8 +188,12 @@ NATS.Jwt.Models.NatsExport.Subject.get -> string! NATS.Jwt.Models.NatsExport.Subject.set -> void NATS.Jwt.Models.NatsExport.TokenReq.get -> bool NATS.Jwt.Models.NatsExport.TokenReq.set -> void -NATS.Jwt.Models.NatsExport.Type.get -> int +NATS.Jwt.Models.NatsExport.Type.get -> NATS.Jwt.Models.NatsExportType NATS.Jwt.Models.NatsExport.Type.set -> void +NATS.Jwt.Models.NatsExportType +NATS.Jwt.Models.NatsExportType.Service = 2 -> NATS.Jwt.Models.NatsExportType +NATS.Jwt.Models.NatsExportType.Stream = 1 -> NATS.Jwt.Models.NatsExportType +NATS.Jwt.Models.NatsExportType.Unknown = 0 -> NATS.Jwt.Models.NatsExportType NATS.Jwt.Models.NatsExternalAuthorization NATS.Jwt.Models.NatsExternalAuthorization.AllowedAccounts.get -> System.Collections.Generic.List! NATS.Jwt.Models.NatsExternalAuthorization.AllowedAccounts.set -> void @@ -227,7 +231,7 @@ NATS.Jwt.Models.NatsImport.To.get -> string! NATS.Jwt.Models.NatsImport.To.set -> void NATS.Jwt.Models.NatsImport.Token.get -> string! NATS.Jwt.Models.NatsImport.Token.set -> void -NATS.Jwt.Models.NatsImport.Type.get -> int +NATS.Jwt.Models.NatsImport.Type.get -> NATS.Jwt.Models.NatsExportType NATS.Jwt.Models.NatsImport.Type.set -> void NATS.Jwt.Models.NatsMsgTrace NATS.Jwt.Models.NatsMsgTrace.Destination.get -> string!