Add validation to RealmCreate #2

Merged
eelke merged 1 commit from validation into main 2026-02-08 17:03:34 +00:00
16 changed files with 326 additions and 23 deletions

View file

@ -0,0 +1,61 @@
using System.Net;
using System.Net.Http.Json;
using FluentResults;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Services;
using IdentityShroud.Core.Tests.Fixtures;
using Microsoft.AspNetCore.Mvc;
using NSubstitute.ClearExtensions;
namespace IdentityShroud.Api.Tests.Apis;
public class RealmApisTests(ApplicationFactory factory) : IClassFixture<ApplicationFactory>
{
[Theory]
[InlineData(null, null, null, false, "Name")]
[InlineData(null, null, "Foo", true, "")]
[InlineData(null, null, "", false, "Name")]
[InlineData(null, "foo", "Foo", true, "")]
[InlineData(null, "f/oo", "Foo", false, "Slug")]
[InlineData(null, "", "Foo", false, "Slug")]
[InlineData("0814934a-efe2-4784-ba84-a184c0b9de9e", "foo", "Foo", true, "")]
[InlineData("00000000-0000-0000-0000-000000000000", "foo", "Foo", false, "Id")]
public async Task Create(string? id, string? slug, string? name, bool succeeds, string fieldName)
{
var client = factory.CreateClient();
factory.RealmService.ClearSubstitute();
factory.RealmService.Create(Arg.Any<RealmCreateRequest>(), Arg.Any<CancellationToken>())
.Returns(Result.Ok(new RealmCreateResponse(Guid.NewGuid(), "foo", "Foo")));
Guid? inputId = id is null ? (Guid?)null : new Guid(id);
var response = await client.PostAsync("/realms", JsonContent.Create(new
{
Id = inputId,
Slug = slug,
Name = name,
}),
TestContext.Current.CancellationToken);
#if DEBUG
string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
#endif
if (succeeds)
{
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
await factory.RealmService.Received(1).Create(
Arg.Is<RealmCreateRequest>(r => r.Id == inputId && r.Slug == slug && r.Name == name),
Arg.Any<CancellationToken>());
}
else
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName);
await factory.RealmService.DidNotReceive().Create(
Arg.Any<RealmCreateRequest>(),
Arg.Any<CancellationToken>());
}
}
}

View file

@ -0,0 +1,24 @@
using IdentityShroud.Core.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestPlatform.TestHost;
namespace IdentityShroud.Core.Tests.Fixtures;
public class ApplicationFactory : WebApplicationFactory<Program>
{
public IRealmService RealmService { get; } = Substitute.For<IRealmService>();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
services.AddScoped<IRealmService>(c => RealmService);
});
builder.UseEnvironment("Development");
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers" Version="4.10.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.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.Api\IdentityShroud.Api.csproj" />
</ItemGroup>
</Project>

View file

@ -1,3 +1,5 @@
using FluentResults;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Services;
@ -6,24 +8,37 @@ using Microsoft.AspNetCore.Mvc;
namespace IdentityShroud.Api;
public static class RealmController
public static class RealmApi
{
public static void MapRealmEndpoints(this IEndpointRouteBuilder app)
{
var realm = app.MapGroup("/realms/{slug}");
var realmsGroup = app.MapGroup("/realms");
realmsGroup.MapPost("", RealmCreate)
.Validate<RealmCreateRequest>()
.WithName("Create Realm")
.Produces(StatusCodes.Status201Created);
realm.MapGet("", GetRoot);
realm.MapPost("", (RealmCreateRequest request, [FromServices] RealmService service) =>
service.Create(request))
.WithName("Create Realm");
realm.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var realmSlugGroup = app.MapGroup("{slug}");
realmSlugGroup.MapGet("", GetRealmInfo);
realmSlugGroup.MapGet(".well-known/openid-configuration", GetOpenIdConfiguration);
var openidConnect = realm.MapGroup("openid-connect");
var openidConnect = realmSlugGroup.MapGroup("openid-connect");
openidConnect.MapPost("auth", OpenIdConnectAuth);
openidConnect.MapPost("token", OpenIdConnectToken);
openidConnect.MapGet("jwks", OpenIdConnectJwks);
}
private static async Task<Results<Created<RealmCreateResponse>, InternalServerError>>
RealmCreate(RealmCreateRequest request, [FromServices] IRealmService service)
{
var response = await service.Create(request);
if (response.IsSuccess)
return TypedResults.Created($"/realms/{response.Value.Slug}", response.Value);
// TODO make helper to convert failure response to a proper HTTP result.
return TypedResults.InternalServerError();
}
private static Task OpenIdConnectJwks(HttpContext context)
{
throw new NotImplementedException();
@ -57,7 +72,7 @@ public static class RealmController
}, AppJsonSerializerContext.Default.OpenIdConfiguration);
}
private static string GetRoot()
private static string GetRealmInfo()
{
return "Hello World!";

View file

@ -10,7 +10,12 @@
<UserSecretsId>6b8ef434-0577-4a3c-8749-6b547d7787c5</UserSecretsId>
</PropertyGroup>
<PropertyGroup>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Serilog" Version="4.3.0" />

View file

@ -1,10 +1,13 @@
using FluentValidation;
using IdentityShroud.Api;
using IdentityShroud.Api.Validation;
using IdentityShroud.Core;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using Serilog;
using Serilog.Formatting.Json;
// Initial logging until we can set it up from Configuration
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
@ -34,6 +37,8 @@ void ConfigureBuilder(WebApplicationBuilder builder)
services.AddOptions<DbConfiguration>().Bind(configuration.GetSection("db"));
services.AddSingleton<ISecretProvider, ConfigurationSecretProvider>();
services.AddValidatorsFromAssemblyContaining<RealmCreateRequestValidator>();
builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
//.Enrich.With<UserEnricher>()
@ -51,3 +56,5 @@ void ConfigureApplication(WebApplication app)
// app.UseRouting();
// app.MapControllers();
}
public partial class Program { }

View file

@ -0,0 +1,7 @@
namespace IdentityShroud.Api.Validation;
public static class EndpointRouteBuilderExtensions
{
public static RouteHandlerBuilder Validate<TDto>(this RouteHandlerBuilder builder) where TDto : class
=> builder.AddEndpointFilter<ValidateFilter<TDto>>();
}

View file

@ -0,0 +1,19 @@
using FluentValidation;
using IdentityShroud.Core.Messages.Realm;
namespace IdentityShroud.Api.Validation;
public class RealmCreateRequestValidator : AbstractValidator<RealmCreateRequest>
{
private const string SlugPattern = @"^(?=.{1,40}$)[a-z0-9]+(?:-[a-z0-9]+)*$";
public RealmCreateRequestValidator()
{
RuleFor(x => x.Id)
.NotEqual(Guid.Empty).When(x => x.Id.HasValue);
RuleFor(x => x.Slug)
.Matches(SlugPattern).Unless(x => x.Slug is null);
RuleFor(x => x.Name)
.NotNull().Length(1, 255);
}
}

View file

@ -0,0 +1,33 @@
using FluentValidation;
namespace IdentityShroud.Api.Validation;
public class ValidateFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Grab the deserialized argument (the DTO) from the context.
// The order of arguments matches the order of parameters in the endpoint delegate.
var dto = context.Arguments
.FirstOrDefault(arg => arg is T) as T;
if (dto == null)
return Results.BadRequest("Unable to read request body.");
// Resolve the matching validator from DI.
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator != null)
{
var validationResult = await validator.ValidateAsync(dto);
if (!validationResult.IsValid)
return Results.ValidationProblem(validationResult.ToDictionary());
}
// Validation passed continue to the actual handler.
return await next(context);
}
}

View file

@ -41,12 +41,13 @@ public class RealmServiceTests : IClassFixture<DbFixture>
RealmCreateResponse val = ResultAssert.Success(response);
if (realmId.HasValue)
Assert.Equal(realmId, val.Realm.Id);
Assert.Equal(realmId, val.Id);
else
Assert.NotEqual(Guid.Empty, val.Realm.Id);
Assert.NotEqual(Guid.Empty, val.Id);
Assert.Equal("slug", val.Realm.Slug);
Assert.Equal("New realm", val.Realm.Name);
Assert.NotEmpty(val.Realm.PrivateKeyEncrypted);
Assert.Equal("slug", val.Slug);
Assert.Equal("New realm", val.Name);
// TODO verify data has been stored!
}
}

View file

@ -1,3 +1,3 @@
namespace IdentityShroud.Core.Messages.Realm;
public record RealmCreateRequest(Guid? Id, string Slug, string Description);
public record RealmCreateRequest(Guid? Id, string? Slug, string Name);

View file

@ -0,0 +1,85 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core.Helpers;
public static class SlugHelper
{
public static string GenerateSlug(string text, int maxLength = 40)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
// Normalize to decomposed form (separates accents from letters)
string normalized = text.Normalize(NormalizationForm.FormD);
StringBuilder sb = new StringBuilder(normalized.Length);
bool lastWasHyphen = false;
foreach (char c in normalized)
{
// Skip diacritics (accents)
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
continue;
char lower = char.ToLowerInvariant(c);
// Convert valid characters
if ((lower >= 'a' && lower <= 'z') || (lower >= '0' && lower <= '9'))
{
sb.Append(lower);
lastWasHyphen = false;
}
// Convert spaces, underscores, and other separators to hyphen
else if (char.IsWhiteSpace(c) || c == '_' || c == '-')
{
if (!lastWasHyphen && sb.Length > 0)
{
sb.Append('-');
lastWasHyphen = true;
}
}
// Skip all other characters
}
// Trim trailing hyphen if any
if (sb.Length > 0 && sb[sb.Length - 1] == '-')
sb.Length--;
string slug = sb.ToString();
// Handle truncation with hash suffix for long strings
if (slug.Length > maxLength)
{
// Generate hash of original text
string hashSuffix = GenerateHashSuffix(text);
int contentLength = maxLength - hashSuffix.Length;
// Truncate at word boundary if possible
int cutPoint = contentLength;
int lastHyphen = slug.LastIndexOf('-', contentLength - 1);
if (lastHyphen > contentLength / 2)
cutPoint = lastHyphen;
slug = slug.Substring(0, cutPoint).TrimEnd('-') + hashSuffix;
}
return slug;
}
private static string GenerateHashSuffix(string text)
{
using (var sha256 = SHA256.Create())
{
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(text));
// Take first 4 bytes (will become ~5-6 base64url chars)
string base64Url = WebEncoders.Base64UrlEncode(hash, 0, 4);
return "-" + base64Url;
}
}
}

View file

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

View file

@ -1,23 +1,24 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
namespace IdentityShroud.Core.Services;
public record RealmCreateResponse(Realm Realm);
public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService(
Db db,
IEncryptionService encryptionService)
IEncryptionService encryptionService) : IRealmService
{
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
{
Realm realm = new()
{
Id = request.Id ?? Guid.CreateVersion7(),
Slug = request.Slug,
Name = request.Description,
Slug = request.Slug ?? SlugHelper.GenerateSlug(request.Name),
Name = request.Name,
};
using RSA rsa = RSA.Create(2048);
@ -26,6 +27,7 @@ public class RealmService(
db.Add(realm);
await db.SaveChangesAsync(ct);
return new RealmCreateResponse(realm);
return new RealmCreateResponse(
realm.Id, realm.Slug, realm.Name);
}
}

View file

@ -7,12 +7,11 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Core.Tests", "IdentityShroud.Core.Tests\IdentityShroud.Core.Tests.csproj", "{DC887623-8680-4D3B-B23A-D54F7DA91891}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{576359FF-C672-4CC3-A683-3BB9D647E75D}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Migrations", "IdentityShroud.Migrations\IdentityShroud.Migrations.csproj", "{DEECABE3-8934-4696-B0E1-48738DD0CEC4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityShroud.Api.Tests", "IdentityShroud.Api.Tests\IdentityShroud.Api.Tests.csproj", "{4758FE2E-A437-44F0-B58E-09E52D67D288}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -35,5 +34,9 @@ Global
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DEECABE3-8934-4696-B0E1-48738DD0CEC4}.Release|Any CPU.Build.0 = Release|Any CPU
{4758FE2E-A437-44F0-B58E-09E52D67D288}.Debug|Any CPU.ActiveCfg = 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.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -7,6 +7,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOkOfT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe2a19de442f561af862af2dcad0852b7e62707a5cf194d266d1656f92bbb6d2_003FOkOfT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATypedResults_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fcea118513a410f660e578fe32bed95cf86457dd135e4b4632ca91eb4f7b_003FTypedResults_002Ecs/@EntryIndexedValue">ForceIncluded</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/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;