Add tests and fixes to .well-known/openid-configuration and create realm
This commit is contained in:
parent
e07d6e3ea5
commit
d440979451
17 changed files with 642 additions and 45 deletions
|
|
@ -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;
|
||||
|
||||
|
|
@ -50,7 +53,9 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
else
|
||||
{
|
||||
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);
|
||||
await factory.RealmService.DidNotReceive().Create(
|
||||
|
|
@ -58,4 +63,39 @@ public class RealmApisTests(ApplicationFactory factory) : IClassFixture<Applicat
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IdentityShroud.Api\IdentityShroud.Api.csproj" />
|
||||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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))
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IdentityShroud.Core\IdentityShroud.Core.csproj" />
|
||||
<ProjectReference Include="..\IdentityShroud.TestUtils\IdentityShroud.TestUtils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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<byte[]>())
|
||||
.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<byte[]>());
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ public class Db(
|
|||
: DbContext
|
||||
{
|
||||
public virtual DbSet<Realm> Realms { get; set; }
|
||||
public virtual DbSet<Key> Keys { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
|
|
|||
45
IdentityShroud.Core/Model/Key.cs
Normal file
45
IdentityShroud.Core/Model/Key.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
/// <summary>
|
||||
|
|
@ -20,26 +18,5 @@ public class Realm
|
|||
public string Name { get; set; } = "";
|
||||
public List<Client> 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<Key> Keys { get; init; } = [];
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
using IdentityShroud.Core.Messages.Realm;
|
||||
using IdentityShroud.Core.Model;
|
||||
|
||||
namespace IdentityShroud.Core.Services;
|
||||
|
||||
public interface IRealmService
|
||||
{
|
||||
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
|
||||
|
||||
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -12,6 +12,11 @@ public class RealmService(
|
|||
Db db,
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
364
IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs
Normal file
364
IdentityShroud.TestUtils/Asserts/JsonObjectAssert.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using FluentResults;
|
||||
using Xunit;
|
||||
|
||||
namespace IdentityShroud.Core.Tests;
|
||||
|
||||
15
IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj
Normal file
15
IdentityShroud.TestUtils/IdentityShroud.TestUtils.csproj
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/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/UnitTesting/UnitTestSessionStore/Sessions/=7d190ab0_002D4f9d_002D4f9f_002Dad83_002Da57b539f3bbd/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=5468c5de_002Dd2de_002D4c6e_002D97d4_002Dbb5f43ed1090/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=a4b5fea0_002D4511_002D4f66_002D888d_002Daea8a1e4c94d/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=b6b17914_002D7f7b_002D403e_002Db1eb_002D2c847c515018/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
|
||||
</wpf:ResourceDictionary>
|
||||
Loading…
Add table
Add a link
Reference in a new issue