Add tests and fixes to .well-known/openid-configuration and create realm

This commit is contained in:
eelke 2026-02-14 14:50:06 +01:00
parent e07d6e3ea5
commit d440979451
17 changed files with 642 additions and 45 deletions

View file

@ -1,9 +1,12 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Nodes;
using FluentResults; using FluentResults;
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services; using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures; using IdentityShroud.Core.Tests.Fixtures;
using IdentityShroud.TestUtils.Asserts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NSubstitute.ClearExtensions; using NSubstitute.ClearExtensions;
@ -50,7 +53,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
else else
{ {
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken); var problemDetails =
await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(
TestContext.Current.CancellationToken);
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
await factory.RealmService.DidNotReceive().Create( await factory.RealmService.DidNotReceive().Create(
@ -58,4 +63,39 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
} }
[Fact]
public async Task GetOpenIdConfiguration_Success()
{
// setup
factory.RealmService.FindBySlug(Arg.Is<string>("foo"), Arg.Any<CancellationToken>())
.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<JsonObject>(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);
}
} }

View file

@ -26,6 +26,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" /> <ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -18,7 +18,7 @@ public static class RealmApi
.WithName("Create Realm") .WithName("Create Realm")
.Produces(StatusCodes.Status201Created); .Produces(StatusCodes.Status201Created);
var realmSlugGroup = app.MapGroup("{slug}"); var realmSlugGroup = realmsGroup.MapGroup("{slug}");
realmSlugGroup.MapGet("", GetRealmInfo); realmSlugGroup.MapGet("", GetRealmInfo);
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration); realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
@ -54,10 +54,18 @@ public static class RealmApi
throw new NotImplementedException(); throw new NotImplementedException();
} }
private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest>> GetOpenIdConfiguration(string slug, HttpContext context) private static async Task<Results<JsonHttpResult<OpenIdConfiguration>, BadRequest, NotFound>> GetOpenIdConfiguration(
[FromServices]IRealmService realmService,
HttpContext context,
string slug)
{ {
if (string.IsNullOrEmpty(slug)) if (string.IsNullOrEmpty(slug))
return TypedResults.BadRequest(); 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 s = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}";
var searchString = $"realms/{slug}"; var searchString = $"realms/{slug}";
int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase); int index = s.IndexOf(searchString, StringComparison.OrdinalIgnoreCase);

View file

@ -26,6 +26,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" /> <ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -3,7 +3,7 @@ using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Tests.Model; namespace IdentityShroud.Core.Tests.Model;
public class RealmTests public class KeyTests
{ {
[Fact] [Fact]
public void SetNewKey() public void SetNewKey()
@ -16,12 +16,12 @@ public class RealmTests
.Encrypt(Arg.Any<byte[]>()) .Encrypt(Arg.Any<byte[]>())
.Returns(x => encryptedPrivateKey); .Returns(x => encryptedPrivateKey);
Realm realm = new(); Key key = new();
realm.SetPrivateKey(encryptionService, privateKey); key.SetPrivateKey(encryptionService, privateKey);
// should be able to return original without calling decrypt // should be able to return original without calling decrypt
Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); Assert.Equal(privateKey, key.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted);
encryptionService.Received(1).Encrypt(privateKey); encryptionService.Received(1).Encrypt(privateKey);
encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>()); encryptionService.DidNotReceive().Decrypt(Arg.Any<byte[]>());
@ -38,12 +38,12 @@ public class RealmTests
.Decrypt(encryptedPrivateKey) .Decrypt(encryptedPrivateKey)
.Returns(x => privateKey); .Returns(x => privateKey);
Realm realm = new(); Key key = new();
realm.PrivateKeyEncrypted = encryptedPrivateKey; key.PrivateKeyEncrypted = encryptedPrivateKey;
// should be able to return original without calling decrypt // should be able to return original without calling decrypt
Assert.Equal(privateKey, realm.GetPrivateKey(encryptionService)); Assert.Equal(privateKey, key.GetPrivateKey(encryptionService));
Assert.Equal(encryptedPrivateKey, realm.PrivateKeyEncrypted); Assert.Equal(encryptedPrivateKey, key.PrivateKeyEncrypted);
encryptionService.Received(1).Decrypt(encryptedPrivateKey); encryptionService.Received(1).Decrypt(encryptedPrivateKey);
} }

View file

@ -17,6 +17,7 @@ public class Db(
: DbContext : DbContext
{ {
public virtual DbSet<Realm> Realms { get; set; } public virtual DbSet<Realm> Realms { get; set; }
public virtual DbSet<Key> Keys { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View file

@ -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; }
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -1,13 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
namespace IdentityShroud.Core.Model; namespace IdentityShroud.Core.Model;
[Table("realm")] [Table("realm")]
public class Realm public class Realm
{ {
private byte[] _privateKeyDecrypted = [];
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary> /// <summary>
@ -20,26 +18,5 @@ public class Realm
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public List<Client> Clients { get; init; } = []; public List<Client> Clients { get; init; } = [];
public byte[] PrivateKeyEncrypted public List<Key> Keys { get; init; } = [];
{
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;
}
} }

View file

@ -1,8 +1,11 @@
using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Services; namespace IdentityShroud.Core.Services;
public interface IRealmService public interface IRealmService
{ {
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default); Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
} }

View file

@ -12,6 +12,11 @@ public class RealmService(
Db db, Db db,
IEncryptionService encryptionService) : IRealmService IEncryptionService encryptionService) : IRealmService
{ {
public Task<Realm?> FindBySlug(string slug, CancellationToken ct = default)
{
throw new NotImplementedException();
}
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default) public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
{ {
Realm realm = new() Realm realm = new()
@ -19,10 +24,10 @@ public class RealmService(
Id = request.Id ?? Guid.CreateVersion7(), Id = request.Id ?? Guid.CreateVersion7(),
Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name), Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name),
Name = request.Name, Name = request.Name,
Keys = [ CreateKey() ],
}; };
using RSA rsa = RSA.Create(2048);
realm.SetPrivateKey(encryptionService, rsa.ExportPkcs8PrivateKey());
db.Add(realm); db.Add(realm);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@ -30,4 +35,17 @@ public class RealmService(
return new RealmCreateResponse( return new RealmCreateResponse(
realm.Id, realm.Slug, realm.Name); 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;
}
} }

View file

@ -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<XunitException>(
() => 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<XunitException>(
() => 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<XunitException>(
() => 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"]);
}
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
<Using Include="NSubstitute"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,364 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Xunit;
namespace IdentityShroud.TestUtils.Asserts;
public static class JsonObjectAssert
{
/// <summary>
/// Parses a path string that may contain array indices (e.g., "items[0].name") into individual segments.
/// </summary>
/// <param name="path">The path string with optional array indices</param>
/// <returns>Array of path segments where array indices are separate segments</returns>
public static string[] ParsePath(string path)
{
var segments = new List<string>();
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();
}
/// <summary>
/// Navigates to a JsonNode at the specified path and returns it.
/// Throws XunitException if the path doesn't exist or is invalid.
/// </summary>
/// <param name="jsonObject">The root JsonObject to navigate from</param>
/// <param name="pathArray">The path segments to navigate</param>
/// <returns>The JsonNode at the specified path (can be null if the value is null)</returns>
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;
}
/// <summary>
/// Asserts that a JsonObject contains the expected value at the specified path.
/// Validates that the path exists, field types match, and values are equal.
/// </summary>
/// <typeparam name="T">The expected type of the value</typeparam>
/// <param name="expected">The expected value</param>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the field as an enumerable of property names</param>
public static void Equal<T>(T expected, JsonObject jsonObject, IEnumerable<string> 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<T>();
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}");
}
}
/// <summary>
/// Asserts that a JsonObject contains the expected value at the specified path.
/// Validates that the path exists, field types match, and values are equal.
/// </summary>
/// <typeparam name="T">The expected type of the value</typeparam>
/// <param name="expected">The expected value</param>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the field as dot-separated string with optional array indices (e.g., "user.addresses[0].city")</param>
public static void Equal<T>(T expected, JsonObject jsonObject, string path)
{
Equal(expected, jsonObject, ParsePath(path));
}
/// <summary>
/// Asserts that a path exists in the JsonObject without validating the value.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to check for existence</param>
public static void PathExists(JsonObject jsonObject, IEnumerable<string> path)
{
var pathArray = path.ToArray();
NavigateToPath(jsonObject, pathArray);
// If NavigateToPath doesn't throw, the path exists
}
/// <summary>
/// Asserts that a path exists in the JsonObject without validating the value.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to check for existence as dot-separated string with optional array indices</param>
public static void PathExists(JsonObject jsonObject, string path)
{
PathExists(jsonObject, ParsePath(path));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="expectedCount">The expected number of elements in the array</param>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array as an enumerable of property names</param>
public static void Count(int expectedCount, JsonObject jsonObject, IEnumerable<string> 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}");
}
/// <summary>
/// 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.
/// </summary>
/// <param name="expectedCount">The expected number of elements in the array</param>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array as dot-separated string with optional array indices (e.g., "user.addresses")</param>
public static void Count(int expectedCount, JsonObject jsonObject, string path)
{
Count(expectedCount, jsonObject, ParsePath(path));
}
/// <summary>
/// Gets a JsonArray at the specified path for performing custom assertions on its elements.
/// Validates that the path exists and is a JsonArray.
/// </summary>
/// <param name="jsonObject">The JsonObject to navigate</param>
/// <param name="path">The path to the array as an enumerable of property names</param>
/// <returns>The JsonArray at the specified path</returns>
public static JsonArray GetArray(JsonObject jsonObject, IEnumerable<string> 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;
}
/// <summary>
/// Gets a JsonArray at the specified path for performing custom assertions on its elements.
/// Validates that the path exists and is a JsonArray.
/// </summary>
/// <param name="jsonObject">The JsonObject to navigate</param>
/// <param name="path">The path to the array as dot-separated string with optional array indices (e.g., "user.addresses")</param>
/// <returns>The JsonArray at the specified path</returns>
public static JsonArray GetArray(JsonObject jsonObject, string path)
{
return GetArray(jsonObject, ParsePath(path));
}
/// <summary>
/// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array</param>
/// <param name="predicate">The predicate to test each element against</param>
public static void All(JsonObject jsonObject, IEnumerable<string> path, Func<JsonNode?, bool> 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}'");
}
}
/// <summary>
/// Asserts that all elements in a JsonArray at the specified path satisfy the given predicate.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array as dot-separated string</param>
/// <param name="predicate">The predicate to test each element against</param>
public static void All(JsonObject jsonObject, string path, Func<JsonNode?, bool> predicate)
{
All(jsonObject, ParsePath(path), predicate);
}
/// <summary>
/// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array</param>
/// <param name="predicate">The predicate to test each element against</param>
public static void Any(JsonObject jsonObject, IEnumerable<string> path, Func<JsonNode?, bool> 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");
}
/// <summary>
/// Asserts that at least one element in a JsonArray at the specified path satisfies the given predicate.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array as dot-separated string</param>
/// <param name="predicate">The predicate to test each element against</param>
public static void Any(JsonObject jsonObject, string path, Func<JsonNode?, bool> predicate)
{
Any(jsonObject, ParsePath(path), predicate);
}
/// <summary>
/// Performs an action on each element in a JsonArray at the specified path.
/// Useful for running custom assertions on each element.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array</param>
/// <param name="assertAction">The action to perform on each element</param>
public static void ForEach(JsonObject jsonObject, IEnumerable<string> path, Action<JsonNode?, int> assertAction)
{
var array = GetArray(jsonObject, path);
for (int i = 0; i < array.Count; i++)
{
assertAction(array[i], i);
}
}
/// <summary>
/// Performs an action on each element in a JsonArray at the specified path.
/// Useful for running custom assertions on each element.
/// </summary>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="path">The path to the array as dot-separated string</param>
/// <param name="assertAction">The action to perform on each element (element, index)</param>
public static void ForEach(JsonObject jsonObject, string path, Action<JsonNode?, int> assertAction)
{
ForEach(jsonObject, ParsePath(path), assertAction);
}
/// <summary>
/// Asserts that a JsonArray at the specified path contains an element with a specific value at a property path.
/// </summary>
/// <typeparam name="T">The expected type of the value</typeparam>
/// <param name="jsonObject">The JsonObject to validate</param>
/// <param name="arrayPath">The path to the array</param>
/// <param name="propertyPath">The property path within each array element to check</param>
/// <param name="expectedValue">The expected value</param>
public static void Contains<T>(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<T>();
if (EqualityComparer<T>.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}");
}
}

View file

@ -1,4 +1,5 @@
using FluentResults; using FluentResults;
using Xunit;
namespace IdentityShroud.Core.Tests; namespace IdentityShroud.Core.Tests;

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="xunit.v3.assert" Version="3.2.2" />
</ItemGroup>
</Project>

View file

@ -12,6 +12,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{4758FE2E-A437-44F0-B58E-09E52D67D288}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal

View file

@ -10,7 +10,14 @@
<s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String> <s:String x:Key="/Default/dotCover/Editor/HighlightingSourceSnapshotLocation/@EntryValue">/home/eelke/.cache/JetBrains/Rider2025.3/resharper-host/temp/Rider/vAny/CoverageData/_IdentityShroud.-1277985570/Snapshot/snapshot.utdcvr</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/eelke/.dotnet/dotnet</s:String>
<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String> <s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/eelke/.dotnet/sdk/10.0.102/MSBuild.dll</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=7d190ab0_002D4f9d_002D4f9f_002Dad83_002Da57b539f3bbd/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt; <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=5468c5de_002Dd2de_002D4c6e_002D97d4_002Dbb5f43ed1090/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt; &lt;Solution /&gt;
&lt;/SessionState&gt;</s:String> &lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b6b17914_002D7f7b_002D403e_002Db1eb_002D2c847c515018/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
</wpf:ResourceDictionary> </wpf:ResourceDictionary>