From 3d73a9914c5ff5b9a139277cad57c72865a2c4d2 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 22 Feb 2026 09:27:48 +0100 Subject: [PATCH] Tests voor client api and service --- .../Apis/ClientApiTests.cs | 179 ++++++++++++++++++ IdentityShroud.Api/Apis/ClientApi.cs | 14 +- .../Apis/Dto/ClientRepresentation.cs | 16 ++ .../Apis/Filters/ClientIdValidationFilter.cs | 3 +- .../Apis/Mappers/ClientMapper.cs | 11 ++ .../ClientCreateRequestValidator.cs | 22 +++ IdentityShroud.Api/IdentityShroud.Api.csproj | 1 + .../Services/ClientServiceTests.cs | 4 +- .../Contracts/IClientService.cs | 16 +- .../DTO/Client/ClientCreateRequest.cs | 10 + IdentityShroud.Core/Services/ClientService.cs | 14 +- IdentityShroud.sln.DotSettings.user | 7 +- 12 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 IdentityShroud.Api.Tests/Apis/ClientApiTests.cs create mode 100644 IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs create mode 100644 IdentityShroud.Api/Apis/Mappers/ClientMapper.cs create mode 100644 IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs create mode 100644 IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs diff --git a/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs new file mode 100644 index 0000000..db984f1 --- /dev/null +++ b/IdentityShroud.Api.Tests/Apis/ClientApiTests.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Net.Http.Json; +using IdentityShroud.Core; +using IdentityShroud.Core.Model; +using IdentityShroud.Core.Tests.Fixtures; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace IdentityShroud.Api.Tests.Apis; + +public class ClientApiTests : IClassFixture +{ + private readonly ApplicationFactory _factory; + + public ClientApiTests(ApplicationFactory factory) + { + _factory = factory; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Database.EnsureCreated()) + { + db.Database.ExecuteSqlRaw("TRUNCATE realm CASCADE;"); + } + } + + [Theory] + [InlineData(null, false, "ClientId")] + [InlineData("", false, "ClientId")] + [InlineData("my-client", true, "")] + public async Task Create_Validation(string? clientId, bool succeeds, string fieldName) + { + // setup + Realm realm = await CreateRealmAsync("test-realm", "Test Realm"); + + var client = _factory.CreateClient(); + + // act + var response = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + JsonContent.Create(new { ClientId = clientId }), + TestContext.Current.CancellationToken); + +#if DEBUG + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); +#endif + + if (succeeds) + { + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + else + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problemDetails = + await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + Assert.Contains(problemDetails!.Errors, e => e.Key == fieldName); + } + } + + [Fact] + public async Task Create_Success_ReturnsCreatedWithLocation() + { + // setup + Realm realm = await CreateRealmAsync("create-realm", "Create Realm"); + + var client = _factory.CreateClient(); + + // act + var response = await client.PostAsync( + $"/api/v1/realms/{realm.Id}/clients", + JsonContent.Create(new { ClientId = "new-client", Name = "New Client" }), + TestContext.Current.CancellationToken); + +#if DEBUG + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); +#endif + + // verify + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + Assert.NotNull(body); + Assert.Equal("new-client", body.ClientId); + Assert.True(body.Id > 0); + } + + [Fact] + public async Task Create_UnknownRealm_ReturnsNotFound() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsync( + $"/api/v1/realms/{Guid.NewGuid()}/clients", + JsonContent.Create(new { ClientId = "some-client" }), + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Get_Success() + { + // setup + Realm realm = await CreateRealmAsync("get-realm", "Get Realm"); + Client dbClient = await CreateClientAsync(realm, "get-client", "Get Client"); + + var httpClient = _factory.CreateClient(); + + // act + var response = await httpClient.GetAsync( + $"/api/v1/realms/{realm.Id}/clients/{dbClient.Id}", + TestContext.Current.CancellationToken); + +#if DEBUG + string contents = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); +#endif + + // verify + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + + Assert.NotNull(body); + Assert.Equal(dbClient.Id, body.Id); + Assert.Equal("get-client", body.ClientId); + Assert.Equal("Get Client", body.Name); + Assert.Equal(realm.Id, body.RealmId); + } + + [Fact] + public async Task Get_UnknownClient_ReturnsNotFound() + { + // setup + Realm realm = await CreateRealmAsync("notfound-realm", "NotFound Realm"); + + var httpClient = _factory.CreateClient(); + + // act + var response = await httpClient.GetAsync( + $"/api/v1/realms/{realm.Id}/clients/99999", + TestContext.Current.CancellationToken); + + // verify + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private async Task CreateRealmAsync(string slug, string name) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var realm = new Realm { Slug = slug, Name = name }; + db.Realms.Add(realm); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + return realm; + } + + private async Task CreateClientAsync(Realm realm, string clientId, string? name = null) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var client = new Client + { + RealmId = realm.Id, + ClientId = clientId, + Name = name, + CreatedAt = DateTime.UtcNow, + }; + db.Clients.Add(client); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + return client; + } +} diff --git a/IdentityShroud.Api/Apis/ClientApi.cs b/IdentityShroud.Api/Apis/ClientApi.cs index fd3e804..e595e34 100644 --- a/IdentityShroud.Api/Apis/ClientApi.cs +++ b/IdentityShroud.Api/Apis/ClientApi.cs @@ -1,14 +1,14 @@ using FluentResults; +using IdentityShroud.Api.Mappers; using IdentityShroud.Core.Contracts; -using IdentityShroud.Core.Messages.Realm; using IdentityShroud.Core.Model; -using IdentityShroud.Core.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace IdentityShroud.Api; + public record ClientCreateReponse(int Id, string ClientId); /// @@ -34,13 +34,18 @@ public static class ClientApi .WithName(ClientGetRouteName); } - private static Task ClientGet(HttpContext context) + private static Ok ClientGet( + Guid realmId, + int clientId, + HttpContext context) { - throw new NotImplementedException(); + Client client = (Client)context.Items["ClientEntity"]!; + return TypedResults.Ok(new ClientMapper().ToDto(client)); } private static async Task, InternalServerError>> ClientCreate( + Guid realmId, ClientCreateRequest request, [FromServices] IClientService service, HttpContext context, @@ -64,6 +69,5 @@ public static class ClientApi ["realmId"] = realm.Id, ["clientId"] = client.Id, }); - throw new NotImplementedException(); } } \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs new file mode 100644 index 0000000..80b5f13 --- /dev/null +++ b/IdentityShroud.Api/Apis/Dto/ClientRepresentation.cs @@ -0,0 +1,16 @@ +namespace IdentityShroud.Api; + +public record ClientRepresentation +{ + public int Id { get; set; } + public Guid RealmId { get; set; } + public required string ClientId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + + public string? SignatureAlgorithm { get; set; } + + public bool AllowClientCredentialsFlow { get; set; } = false; + + public required DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs index 8030153..771be81 100644 --- a/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs +++ b/IdentityShroud.Api/Apis/Filters/ClientIdValidationFilter.cs @@ -7,8 +7,9 @@ public class ClientIdValidationFilter(IClientService clientService) : IEndpointF { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { + Guid realmId = context.Arguments.OfType().First(); int id = context.Arguments.OfType().First(); - Client? client = await clientService.FindById(id, context.HttpContext.RequestAborted); + Client? client = await clientService.FindById(realmId, id, context.HttpContext.RequestAborted); if (client is null) { return Results.NotFound(); diff --git a/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs new file mode 100644 index 0000000..8e58717 --- /dev/null +++ b/IdentityShroud.Api/Apis/Mappers/ClientMapper.cs @@ -0,0 +1,11 @@ +using IdentityShroud.Core.Model; +using Riok.Mapperly.Abstractions; + +namespace IdentityShroud.Api.Mappers; + +[Mapper] +public partial class ClientMapper +{ + [MapperIgnoreSource(nameof(Client.Secrets))] + public partial ClientRepresentation ToDto(Client client); +} \ No newline at end of file diff --git a/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs new file mode 100644 index 0000000..7666b36 --- /dev/null +++ b/IdentityShroud.Api/Apis/Validation/ClientCreateRequestValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using IdentityShroud.Core.Contracts; + +namespace IdentityShroud.Api; + +public class ClientCreateRequestValidator : AbstractValidator +{ + // most of standard ascii minus the control characters and space + private const string ClientIdPattern = "^[\x21-\x7E]+"; + + private string[] AllowedAlgorithms = [ "RS256", "ES256" ]; + + public ClientCreateRequestValidator() + { + RuleFor(e => e.ClientId).NotEmpty().MaximumLength(40).Matches(ClientIdPattern); + RuleFor(e => e.Name).MaximumLength(80); + RuleFor(e => e.Description).MaximumLength(2048); + RuleFor(e => e.SignatureAlgorithm) + .Must(v => v is null || AllowedAlgorithms.Contains(v)) + .WithMessage($"SignatureAlgorithm must be one of {string.Join(", ", AllowedAlgorithms)} or null"); + } +} \ No newline at end of file diff --git a/IdentityShroud.Api/IdentityShroud.Api.csproj b/IdentityShroud.Api/IdentityShroud.Api.csproj index 72b4639..860fbeb 100644 --- a/IdentityShroud.Api/IdentityShroud.Api.csproj +++ b/IdentityShroud.Api/IdentityShroud.Api.csproj @@ -18,6 +18,7 @@ + diff --git a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs index cb2e772..30bb3b6 100644 --- a/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs +++ b/IdentityShroud.Core.Tests/Services/ClientServiceTests.cs @@ -108,7 +108,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act ClientService sut = new(actContext, _encryptionService, _clock); - Client? result = await sut.GetByClientId(clientId, TestContext.Current.CancellationToken); + Client? result = await sut.GetByClientId(_realmId, clientId, TestContext.Current.CancellationToken); // Verify if (shouldFind) @@ -143,7 +143,7 @@ public class ClientServiceTests : IClassFixture await using var actContext = _dbFixture.CreateDbContext(); // Act ClientService sut = new(actContext, _encryptionService, _clock); - Client? result = await sut.FindById(searchId, TestContext.Current.CancellationToken); + Client? result = await sut.FindById(_realmId, searchId, TestContext.Current.CancellationToken); // Verify if (shouldFind) diff --git a/IdentityShroud.Core/Contracts/IClientService.cs b/IdentityShroud.Core/Contracts/IClientService.cs index 15c0eba..20e270c 100644 --- a/IdentityShroud.Core/Contracts/IClientService.cs +++ b/IdentityShroud.Core/Contracts/IClientService.cs @@ -2,18 +2,6 @@ using IdentityShroud.Core.Model; namespace IdentityShroud.Core.Contracts; -//public record CreateClientRequest(Guid RealmId, string ClientId, string? Description); - -public class ClientCreateRequest -{ - public required string ClientId { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } - public string? SignatureAlgorithm { get; set; } - public bool? AllowClientCredentialsFlow { get; set; } -} - - public interface IClientService { Task> Create( @@ -21,6 +9,6 @@ public interface IClientService ClientCreateRequest request, CancellationToken ct = default); - Task GetByClientId(string clientId, CancellationToken ct = default); - Task FindById(int id, CancellationToken ct = default); + Task GetByClientId(Guid realmId, string clientId, CancellationToken ct = default); + Task FindById(Guid realmId, int id, CancellationToken ct = default); } \ No newline at end of file diff --git a/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs new file mode 100644 index 0000000..a162131 --- /dev/null +++ b/IdentityShroud.Core/DTO/Client/ClientCreateRequest.cs @@ -0,0 +1,10 @@ +namespace IdentityShroud.Core.Contracts; + +public class ClientCreateRequest +{ + public required string ClientId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? SignatureAlgorithm { get; set; } + public bool? AllowClientCredentialsFlow { get; set; } +} \ No newline at end of file diff --git a/IdentityShroud.Core/Services/ClientService.cs b/IdentityShroud.Core/Services/ClientService.cs index 2e556d4..ed85daf 100644 --- a/IdentityShroud.Core/Services/ClientService.cs +++ b/IdentityShroud.Core/Services/ClientService.cs @@ -34,14 +34,20 @@ public class ClientService( return client; } - public async Task GetByClientId(string clientId, CancellationToken ct = default) + public async Task GetByClientId( + Guid realmId, + string clientId, + CancellationToken ct = default) { - return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + return await db.Clients.FirstOrDefaultAsync(c => c.ClientId == clientId && c.RealmId == realmId, ct); } - public async Task FindById(int id, CancellationToken ct = default) + public async Task FindById( + Guid realmId, + int id, + CancellationToken ct = default) { - return await db.Clients.FirstOrDefaultAsync(c => c.Id == id, ct); + return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct); } private ClientSecret CreateSecret() diff --git a/IdentityShroud.sln.DotSettings.user b/IdentityShroud.sln.DotSettings.user index e39022f..01fd911 100644 --- a/IdentityShroud.sln.DotSettings.user +++ b/IdentityShroud.sln.DotSettings.user @@ -22,12 +22,11 @@ /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" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Junie Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <ProjectFile>DC887623-8680-4D3B-B23A-D54F7DA91891/d:Services/f:ClientServiceTests.cs</ProjectFile> -</SessionState> + +