From d440979451a39890afa6a718cdcd9481178d033d Mon Sep 17 00:00:00 2001 From: eelke Date: Sat, 14 Feb 2026 14:50:06 +0100 Subject: [PATCH] Add tests and fixes to .well-known/openid-configuration and create realm --- .../Apis/RealmApisTests.cs | 54 ++- .../IdentityShroud.Api.Tests.csproj | 1 + IdentityShroud.Api/Apis/RealmApi.cs | 12 +- .../IdentityShroud.Core.Tests.csproj | 1 + .../Model/{RealmTests.cs => KeyTests.cs} | 18 +- IdentityShroud.Core/Db.cs | 1 + IdentityShroud.Core/Model/Key.cs | 45 +++ IdentityShroud.Core/Model/Realm.cs | 25 +- IdentityShroud.Core/Services/IRealmService.cs | 3 + IdentityShroud.Core/Services/RealmService.cs | 22 +- .../Asserts/JsonObjectAssertTests.cs | 70 ++++ .../IdentityShroud.TestUtils.Tests.csproj | 26 ++ .../Asserts/JsonObjectAssert.cs | 364 ++++++++++++++++++ .../Asserts/ResultAssert.cs | 1 + .../IdentityShroud.TestUtils.csproj | 15 + IdentityShroud.sln | 20 + IdentityShroud.sln.DotSettings.user | 9 +- 17 files changed, 642 insertions(+), 45 deletions(-) rename IdentityShroud.Core.Tests/Model/{RealmTests.cs => KeyTests.cs} (70%) create mode 100644 IdentityShroud.Core/Model/Key.cs create mode 100644 IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs create mode 100644 IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj create mode 100644 IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs rename {IdentityShroud.Core.Tests => IdentityShroud.TestUtils}/Asserts/ResultAssert.cs (99%) create mode 100644 IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj diff --git a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs index 7e6192e..1b1743b 100644 --- a/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs +++ b/IdentityShroud.Api.Tests/Apis/RealmApisTests.cs @@ -1,9 +1,12 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json.Nodes; using FluentResults; using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; using IdentityShroud.Core.Services; using IdentityShroud.Core.Tests.Fixtures; +using IdentityShroud.TestUtils.Asserts; using Microsoft.AspNetCore.Mvc; using NSubstitute.ClearExtensions; @@ -34,28 +37,65 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture(r => r.Id == inputId && r.Slug == slug && r.Name == name), + Arg.Is(r => r.Id == inputId && r.Slug == slug && r.Name == name), Arg.Any()); } else { Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var problemDetails = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var problemDetails = + await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); await factory.RealmService.DidNotReceive().Create( - Arg.Any(), + Arg.Any(), Arg.Any()); } } + + [Fact] + public async Task GetOpenIdConfiguration_Success() + { + // setup + factory.RealmService.FindBySlug(Arg.Is("foo"), Arg.Any()) + .Returns(new Realm()); + + // act + var client = factory.CreateClient(); + var response = await client.GetAsync("/realms/foo/.well-known/openid-configuration", + TestContext.Current.CancellationToken); + + // verify + var result = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/auth", result, "authorization_endpoint"); + JsonObjectAssert.Equal("http://localhost/realms/foo", result, "issuer"); + JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/token", result, "token_endpoint"); + JsonObjectAssert.Equal("http://localhost/realms/foo/openid-connect/jwks", result, "jwks_uri"); + } + + [Theory] + [InlineData("")] + [InlineData("bar")] + public async Task GetOpenIdConfiguration_NotFound(string slug) + { + // act + var client = factory.CreateClient(); + var response = await client.GetAsync("/realms/bar/.well-known/openid-configuration", + TestContext.Current.CancellationToken); + + // verify + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } } \ No newline at end of file diff --git a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj index 6351c40..a3aa6a8 100644 --- a/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj +++ b/IdentityShroud.Api.Tests/IdentityShroud.Api.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/IdentityShroud.Api/Apis/RealmApi.cs b/IdentityShroud.Api/Apis/RealmApi.cs index 265fbb9..6eb9dab 100644 --- a/IdentityShroud.Api/Apis/RealmApi.cs +++ b/IdentityShroud.Api/Apis/RealmApi.cs @@ -18,7 +18,7 @@ public static class RealmApi .WithName("Create Realm") .Produces(StatusCodes.Status201Created); - var realmSlugGroup = app.MapGroup("{slug}"); + var realmSlugGroup = realmsGroup.MapGroup("{slug}"); realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); @@ -54,10 +54,18 @@ public static class RealmApi throw new NotImplementedException(); } - private static async Task, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context) + private static async Task, BadRequest, NotFound>> GetOpenIdConfiguration( + [FromServices]IRealmService realmService, + HttpContext context, + string slug) { if (string.IsNullOrEmpty(slug)) return TypedResults.BadRequest(); + + var realm = await realmService.FindBySlug(slug); + if (realm is null) + return TypedResults.NotFound(); + var s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; var searchString = $"realms/{slug}"; int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); diff --git a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj index 3cb7db3..bc2d19b 100644 --- a/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj +++ b/IdentityShroud.Core.Tests/IdentityShroud.Core.Tests.csproj @@ -26,6 +26,7 @@ + \ No newline at end of file diff --git a/IdentityShroud.Core.Tests/Model/RealmTests.cs b/IdentityShroud.Core.Tests/Model/KeyTests.cs similarity index 70% rename from IdentityShroud.Core.Tests/Model/RealmTests.cs rename to IdentityShroud.Core.Tests/Model/KeyTests.cs index 959d8b1..e7e9b45 100644 --- a/IdentityShroud.Core.Tests/Model/RealmTests.cs +++ b/IdentityShroud.Core.Tests/Model/KeyTests.cs @@ -3,7 +3,7 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Tests.Model; -public class RealmTests +public class KeyTests { [Fact] public void SetNewKey() @@ -16,12 +16,12 @@ public class RealmTests .Encrypt(Arg.Any()) .Returns(x => encryptedPrivateKey); - Realm realm = new(); - realm.SetPrivateKey(encryptionService, privateKey); + Key key = new(); + key.SetPrivateKey(encryptionService, privateKey); // should be able to return original without calling decrypt - Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); encryptionService.Received(1).Encrypt(privateKey); encryptionService.DidNotReceive().Decrypt(Arg.Any()); @@ -38,12 +38,12 @@ public class RealmTests .Decrypt(encryptedPrivateKey) .Returns(x => privateKey); - Realm realm = new(); - realm.PrivateKeyEncrypted = encryptedPrivateKey; + Key key = new(); + key.PrivateKeyEncrypted = encryptedPrivateKey; // should be able to return original without calling decrypt - Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); - Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); + Assert.Equal(privateKey, key.GetPrivateKey(encryptionService)); + Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted); encryptionService.Received(1).Decrypt(encryptedPrivateKey); } diff --git a/IdentityShroud.Core/Db.cs b/IdentityShroud.Core/Db.cs index 2f95902..b476787 100644 --- a/IdentityShroud.Core/Db.cs +++ b/IdentityShroud.Core/Db.cs @@ -17,6 +17,7 @@ public class Db( : DbContext { public virtual DbSet Realms { get; set; } + public virtual DbSet Keys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/IdentityShroud.Core/Model/Key.cs b/IdentityShroud.Core/Model/Key.cs new file mode 100644 index 0000000..ee09d31 --- /dev/null +++ b/IdentityShroud.Core/Model/Key.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations.Schema; +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Core.Model; + + +[Table("key")] +public class Key +{ + private byte[] _privateKeyDecrypted = []; + + public Guid Id { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? DeactivatedAt { get; set; } + + /// + /// Key with highest priority will be used. While there is not really a use case for this I know some users + /// are more comfortable replacing keys by using priority then directly deactivating the old key. + /// + public int Priority { get; set; } = 10; + + public byte[] PrivateKeyEncrypted + { + get; + set + { + field = value; + _privateKeyDecrypted = []; + } + } = []; + + public byte[] GetPrivateKey(IEncryptionService encryptionService) + { + if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) + _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); + return _privateKeyDecrypted; + } + + public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) + { + PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); + _privateKeyDecrypted = privateKey; + } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Model/Realm.cs b/IdentityShroud.Core/Model/Realm.cs index 5fc9639..641f4b8 100644 --- a/IdentityShroud.Core/Model/Realm.cs +++ b/IdentityShroud.Core/Model/Realm.cs @@ -1,13 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using IdentityShroud.Core.Contracts; namespace IdentityShroud.Core.Model; [Table("realm")] public class Realm { - private byte[] _privateKeyDecrypted = []; public Guid Id { get; set; } /// @@ -20,26 +18,5 @@ public class Realm public string Name { get; set; } = ""; public List Clients { get; init; } = []; - public byte[] PrivateKeyEncrypted - { - get; - set - { - field = value; - _privateKeyDecrypted = []; - } - } = []; - - public byte[] GetPrivateKey(IEncryptionService encryptionService) - { - if (_privateKeyDecrypted.Length == 0 && PrivateKeyEncrypted.Length > 0) - _privateKeyDecrypted = encryptionService.Decrypt(PrivateKeyEncrypted); - return _privateKeyDecrypted; - } - - public void SetPrivateKey(IEncryptionService encryptionService, byte[] privateKey) - { - PrivateKeyEncrypted = encryptionService.Encrypt(privateKey); - _privateKeyDecrypted = privateKey; - } + public List Keys { get; init; } = []; } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/IRealmService.cs b/IdentityShroud.Core/Services/IRealmService.cs index 7a8ef79..26af4d6 100644 --- a/IdentityShroud.Core/Services/IRealmService.cs +++ b/IdentityShroud.Core/Services/IRealmService.cs @@ -1,8 +1,11 @@ using IdentityShroud.Core.Messages.Realm; +using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Services; public interface IRealmService { + Task FindBySlug(string slug, CancellationToken ct = default); + Task> Create(RealmCreateRequest request, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/Services/RealmService.cs b/IdentityShroud.Core/Services/RealmService.cs index 50cb61d..00de987 100644 --- a/IdentityShroud.Core/Services/RealmService.cs +++ b/IdentityShroud.Core/Services/RealmService.cs @@ -12,6 +12,11 @@ public class RealmService( Db db, IEncryptionService encryptionService) : IRealmService { + public Task FindBySlug(string slug, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + public async Task> Create(RealmCreateRequest request, CancellationToken ct = default) { Realm realm = new() @@ -19,10 +24,10 @@ public class RealmService( Id = request.Id ?? Guid.CreateVersion7(), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Name = request.Name, + Keys = [ CreateKey() ], }; - using RSA rsa = RSA.Create(2048); - realm.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + db.Add(realm); await db.SaveChangesAsync(ct); @@ -30,4 +35,17 @@ public class RealmService( return new RealmCreateResponse( realm.Id, realm.Slug, realm.Name); } + + private Key CreateKey() + { + using RSA rsa = RSA.Create(2048); + + Key key = new() + { + Priority = 10, + }; + key.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey()); + + return key; + } } \ No newline at end of file diff --git a/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs b/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs new file mode 100644 index 0000000..1ccaf34 --- /dev/null +++ b/IdentityShroud.TestUtils.Tests/Asserts/JsonObjectAssertTests.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Nodes; +using IdentityShroud.TestUtils.Asserts; +using Xunit.Sdk; + +namespace IdentityShroud.TestUtils.Tests.Asserts; + +public class JsonObjectAssertTests +{ + [Theory] + [InlineData("foo", new string[] { "foo" })] + [InlineData("foo.bar", new string[] { "foo", "bar" })] + [InlineData("foo[1].bar", new string[] { "foo", "1", "bar" })] + public void ParsePath(string path, string[] expected) + { + var result = JsonObjectAssert.ParsePath(path); + Assert.Equal(expected, result); + } + + [Fact] + public void NavigateToPath_Success() + { + JsonObject foo = new(); + foo["bar"] = 1; + JsonObject obj = new(); + obj["foo"] = foo; + + JsonNode? node = JsonObjectAssert.NavigateToPath(obj, ["foo", "bar"]); + Assert.NotNull(node); + } + + [Fact] + public void NavigateToPath_PathDoesNotExist() + { + JsonObject obj = new(); + Assert.Throws( + () => JsonObjectAssert.NavigateToPath(obj, ["test"]), + ex => ex.Message.StartsWith("Path 'test' does not exist") ? null : ex.Message); + } + + [Fact] + public void NavigateToPath_MemberOfNullObject() + { + JsonObject obj = new(); + obj["foo"] = null; + + Assert.Throws( + () => JsonObjectAssert.NavigateToPath(obj, ["foo", "bar"]), + ex => ex.Message.StartsWith("Path 'foo.bar' does not exist") ? null : ex.Message); + } + + [Fact] + public void Equal_WrongType() + { + JsonObject obj = new(); + obj["test"] = new JsonObject(); + + Assert.Throws( + () => JsonObjectAssert.Equal("str", obj, ["test"]), + ex => ex.Message.StartsWith("Type mismatch") ? null : ex.Message); + } + + [Fact] + public void Equal_Match() + { + JsonObject obj = new(); + obj["test"] = "str"; + + JsonObjectAssert.Equal("str", obj, ["test"]); + } +} \ No newline at end of file diff --git a/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj new file mode 100644 index 0000000..9ce8074 --- /dev/null +++ b/IdentityShroud.TestUtils.Tests/IdentityShroud.TestUtils.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs new file mode 100644 index 0000000..3352bc6 --- /dev/null +++ b/IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs @@ -0,0 +1,364 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Xunit; + +namespace IdentityShroud.TestUtils.Asserts; + +public static class JsonObjectAssert +{ + /// + /// Parses a path string that may contain array indices (e.g., "items[0].name") into individual segments. + /// + /// The path string with optional array indices + /// Array of path segments where array indices are separate segments + public static string[] ParsePath(string path) + { + var segments = new List(); + var parts = path.Split('.'); + + foreach (var part in parts) + { + // Check if the part contains array indexing like "items[0]" + var match = Regex.Match(part, @"^(.+?)\[(\d+)\]$"); + if (match.Success) + { + // Add the property name + segments.Add(match.Groups[1].Value); + // Add the array index + segments.Add(match.Groups[2].Value); + } + else + { + segments.Add(part); + } + } + + return segments.ToArray(); + } + + /// + /// Navigates to a JsonNode at the specified path and returns it. + /// Throws XunitException if the path doesn't exist or is invalid. + /// + /// The root JsonObject to navigate from + /// The path segments to navigate + /// The JsonNode at the specified path (can be null if the value is null) + public static JsonNode? NavigateToPath(JsonObject jsonObject, string[] pathArray) + { + if (pathArray.Length == 0) + throw new ArgumentException("Path cannot be empty"); + + JsonNode? current = jsonObject; + string currentPath = ""; + + foreach (var segment in pathArray) + { + currentPath = string.IsNullOrEmpty(currentPath) ? segment : $"{currentPath}.{segment}"; + + if (current == null) + throw new Xunit.Sdk.XunitException( + $"Path '{currentPath}' does not exist - parent node is null"); + + if (current is JsonObject obj) + { + if (!obj.ContainsKey(segment)) + throw new Xunit.Sdk.XunitException( + $"Path '{currentPath}' does not exist - property '{segment}' not found"); + + current = obj[segment]; + } + else if (current is JsonArray arr && int.TryParse(segment, out int index)) + { + if (index < 0 || index >= arr.Count) + throw new Xunit.Sdk.XunitException( + $"Path '{currentPath}' does not exist - array index {index} out of bounds (array length: {arr.Count})"); + + current = arr[index]; + } + else + { + throw new Xunit.Sdk.XunitException( + $"Path '{currentPath}' is invalid - cannot navigate through non-object/non-array node at '{segment}'"); + } + } + + return current; + } + + /// + /// Asserts that a JsonObject contains the expected value at the specified path. + /// Validates that the path exists, field types match, and values are equal. + /// + /// The expected type of the value + /// The expected value + /// The JsonObject to validate + /// The path to the field as an enumerable of property names + public static void Equal(T expected, JsonObject jsonObject, IEnumerable path) + { + var pathArray = path.ToArray(); + var current = NavigateToPath(jsonObject, pathArray); + + if (current == null) + { + if (expected != null) + throw new Xunit.Sdk.XunitException( + $"Expected value '{expected}' at path '{string.Join(".", pathArray)}', but found null"); + return; + } + + // Type and value validation + try + { + T? actualValue = current.GetValue(); + Assert.Equal(expected, actualValue); + } + catch (InvalidOperationException ex) + { + throw new Xunit.Sdk.XunitException( + $"Type mismatch at path '{string.Join(".", pathArray)}': cannot convert JsonNode to {typeof(T).Name}. {ex.Message}"); + } + catch (FormatException ex) + { + throw new Xunit.Sdk.XunitException( + $"Format error at path '{string.Join(".", pathArray)}': cannot convert value to {typeof(T).Name}. {ex.Message}"); + } + } + + /// + /// Asserts that a JsonObject contains the expected value at the specified path. + /// Validates that the path exists, field types match, and values are equal. + /// + /// The expected type of the value + /// The expected value + /// The JsonObject to validate + /// The path to the field as dot-separated string with optional array indices (e.g., "user.addresses[0].city") + public static void Equal(T expected, JsonObject jsonObject, string path) + { + Equal(expected, jsonObject, ParsePath(path)); + } + + /// + /// Asserts that a path exists in the JsonObject without validating the value. + /// + /// The JsonObject to validate + /// The path to check for existence + public static void PathExists(JsonObject jsonObject, IEnumerable path) + { + var pathArray = path.ToArray(); + NavigateToPath(jsonObject, pathArray); + // If NavigateToPath doesn't throw, the path exists + } + + /// + /// Asserts that a path exists in the JsonObject without validating the value. + /// + /// The JsonObject to validate + /// The path to check for existence as dot-separated string with optional array indices + public static void PathExists(JsonObject jsonObject, string path) + { + PathExists(jsonObject, ParsePath(path)); + } + + /// + /// Asserts that a JsonArray at the specified path has the expected count. + /// Validates that the path exists, is a JsonArray, and has the expected number of elements. + /// + /// The expected number of elements in the array + /// The JsonObject to validate + /// The path to the array as an enumerable of property names + public static void Count(int expectedCount, JsonObject jsonObject, IEnumerable path) + { + var pathArray = path.ToArray(); + var current = NavigateToPath(jsonObject, pathArray); + var pathString = string.Join(".", pathArray); + + if (current == null) + throw new Xunit.Sdk.XunitException( + $"Path '{pathString}' contains null - cannot verify count on null value"); + + if (current is not JsonArray array) + throw new Xunit.Sdk.XunitException( + $"Path '{pathString}' does not contain a JsonArray - found {current.GetType().Name} instead"); + + if (array.Count != expectedCount) + throw new Xunit.Sdk.XunitException( + $"Expected array at path '{pathString}' to have {expectedCount} element(s), but found {array.Count}"); + } + + /// + /// Asserts that a JsonArray at the specified path has the expected count. + /// Validates that the path exists, is a JsonArray, and has the expected number of elements. + /// + /// The expected number of elements in the array + /// The JsonObject to validate + /// The path to the array as dot-separated string with optional array indices (e.g., "user.addresses") + public static void Count(int expectedCount, JsonObject jsonObject, string path) + { + Count(expectedCount, jsonObject, ParsePath(path)); + } + + /// + /// Gets a JsonArray at the specified path for performing custom assertions on its elements. + /// Validates that the path exists and is a JsonArray. + /// + /// The JsonObject to navigate + /// The path to the array as an enumerable of property names + /// The JsonArray at the specified path + public static JsonArray GetArray(JsonObject jsonObject, IEnumerable path) + { + var pathArray = path.ToArray(); + var current = NavigateToPath(jsonObject, pathArray); + var pathString = string.Join(".", pathArray); + + if (current == null) + throw new Xunit.Sdk.XunitException( + $"Path '{pathString}' contains null - expected a JsonArray"); + + if (current is not JsonArray array) + throw new Xunit.Sdk.XunitException( + $"Path '{pathString}' does not contain a JsonArray - found {current.GetType().Name} instead"); + + return array; + } + + /// + /// Gets a JsonArray at the specified path for performing custom assertions on its elements. + /// Validates that the path exists and is a JsonArray. + /// + /// The JsonObject to navigate + /// The path to the array as dot-separated string with optional array indices (e.g., "user.addresses") + /// The JsonArray at the specified path + public static JsonArray GetArray(JsonObject jsonObject, string path) + { + return GetArray(jsonObject, ParsePath(path)); + } + + /// + /// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate. + /// + /// The JsonObject to validate + /// The path to the array + /// The predicate to test each element against + public static void All(JsonObject jsonObject, IEnumerable path, Func predicate) + { + var array = GetArray(jsonObject, path); + var pathString = string.Join(".", path); + + for (int i = 0; i < array.Count; i++) + { + if (!predicate(array[i])) + throw new Xunit.Sdk.XunitException( + $"Predicate failed for element at index {i} in array at path '{pathString}'"); + } + } + + /// + /// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate. + /// + /// The JsonObject to validate + /// The path to the array as dot-separated string + /// The predicate to test each element against + public static void All(JsonObject jsonObject, string path, Func predicate) + { + All(jsonObject, ParsePath(path), predicate); + } + + /// + /// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate. + /// + /// The JsonObject to validate + /// The path to the array + /// The predicate to test each element against + public static void Any(JsonObject jsonObject, IEnumerable path, Func predicate) + { + var array = GetArray(jsonObject, path); + var pathString = string.Join(".", path); + + foreach (var element in array) + { + if (predicate(element)) + return; + } + + throw new Xunit.Sdk.XunitException( + $"No element in array at path '{pathString}' satisfies the predicate"); + } + + /// + /// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate. + /// + /// The JsonObject to validate + /// The path to the array as dot-separated string + /// The predicate to test each element against + public static void Any(JsonObject jsonObject, string path, Func predicate) + { + Any(jsonObject, ParsePath(path), predicate); + } + + /// + /// Performs an action on each element in a JsonArray at the specified path. + /// Useful for running custom assertions on each element. + /// + /// The JsonObject to validate + /// The path to the array + /// The action to perform on each element + public static void ForEach(JsonObject jsonObject, IEnumerable path, Action assertAction) + { + var array = GetArray(jsonObject, path); + + for (int i = 0; i < array.Count; i++) + { + assertAction(array[i], i); + } + } + + /// + /// Performs an action on each element in a JsonArray at the specified path. + /// Useful for running custom assertions on each element. + /// + /// The JsonObject to validate + /// The path to the array as dot-separated string + /// The action to perform on each element (element, index) + public static void ForEach(JsonObject jsonObject, string path, Action assertAction) + { + ForEach(jsonObject, ParsePath(path), assertAction); + } + + /// + /// Asserts that a JsonArray at the specified path contains an element with a specific value at a property path. + /// + /// The expected type of the value + /// The JsonObject to validate + /// The path to the array + /// The property path within each array element to check + /// The expected value + public static void Contains(JsonObject jsonObject, string arrayPath, string propertyPath, T expectedValue) + { + var array = GetArray(jsonObject, arrayPath); + var propertySegments = ParsePath(propertyPath); + + foreach (var element in array) + { + if (element is JsonObject elementObj) + { + try + { + var current = NavigateToPath(elementObj, propertySegments); + if (current != null) + { + var actualValue = current.GetValue(); + if (EqualityComparer.Default.Equals(actualValue, expectedValue)) + return; + } + } + catch + { + // Continue checking other elements + } + } + } + + throw new Xunit.Sdk.XunitException( + $"Array at path '{arrayPath}' does not contain an element with {propertyPath} = {expectedValue}"); + } +} diff --git a/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs similarity index 99% rename from IdentityShroud.Core.Tests/Asserts/ResultAssert.cs rename to IdentityShroud.TestUtils/Asserts/ResultAssert.cs index ff00c06..28a0b11 100644 --- a/IdentityShroud.Core.Tests/Asserts/ResultAssert.cs +++ b/IdentityShroud.TestUtils/Asserts/ResultAssert.cs @@ -1,4 +1,5 @@ using FluentResults; +using Xunit; namespace IdentityShroud.Core.Tests; diff --git a/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj new file mode 100644 index 0000000..1b6abab --- /dev/null +++ b/IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + false + + + + + + + + diff --git a/IdentityShroud.sln b/IdentityShroud.sln index b0de020..ef65bf2 100644 --- a/IdentityShroud.sln +++ b/IdentityShroud.sln @@ -12,6 +12,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils", "IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj", "{A8554BCC-C9B6-4D96-90AD-FE80E95441F4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.TestUtils.Tests", "IdentityShroud.TestUtils.Tests\IdentityShroud.TestUtils.Tests.csproj", "{35D33207-27A8-43E9-A8CA-A158A1E4448C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{980900AA-E052-498B-A41A-4F33A8678828}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,5 +44,19 @@ Global {4758FE2E-A437-44F0-B58E-09E52D67D288}.Debug|Any CPU.Build.0 = Debug|Any CPU {4758FE2E-A437-44F0-B58E-09E52D67D288}.Release|Any CPU.ActiveCfg = Release|Any CPU {4758FE2E-A437-44F0-B58E-09E52D67D288}.Release|Any CPU.Build.0 = Release|Any CPU + {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8554BCC-C9B6-4D96-90AD-FE80E95441F4}.Release|Any CPU.Build.0 = Release|Any CPU + {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35D33207-27A8-43E9-A8CA-A158A1E4448C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4758FE2E-A437-44F0-B58E-09E52D67D288} = {980900AA-E052-498B-A41A-4F33A8678828} + {DC887623-8680-4D3B-B23A-D54F7DA91891} = {980900AA-E052-498B-A41A-4F33A8678828} + {35D33207-27A8-43E9-A8CA-A158A1E4448C} = {980900AA-E052-498B-A41A-4F33A8678828} + {A8554BCC-C9B6-4D96-90AD-FE80E95441F4} = {980900AA-E052-498B-A41A-4F33A8678828} EndGlobalSection EndGlobal diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index 21dd76b..3107fc6 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -10,7 +10,14 @@ /home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr /home/eelke/.dotnet/dotnet /home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll - <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + \ No newline at end of file