From 1fb7a4d455d108e8bef4e2f88c895f3b49f9f22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20C=C3=A9let?= <1457422+gcelet@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:02:59 +0100 Subject: [PATCH] :white_check_mark: test: add end to end test suite on all supported .net versions (6, 8, 9, 10) --- Directory.Packages.props | 16 +- ProtectedNumbers.sln | 70 ++++- build/Build.cs | 27 +- ...isableChildValidationForProtectedNumber.cs | 24 ++ .../ProtectedNumbersConfigureMvcOptions.cs | 6 + .../AppHost.cs | 10 + .../Properties/launchSettings.json | 31 ++ ...ectedNumbers.Tests.EndToEnd.AppHost.csproj | 19 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Extensions.cs | 129 +++++++++ ...bers.Tests.EndToEnd.ServiceDefaults.csproj | 23 ++ .../Controllers/SampleObjectController.cs | 100 +++++++ .../SampleObjects/GetByIdEndpoint.cs | 47 +++ .../SampleObjectGetAllEndpoint.cs | 35 +++ .../Endpoints/SampleObjects/SaveEndpoint.cs | 49 ++++ .../Endpoints/SampleObjects/SearchEndpoint.cs | 37 +++ .../Extensions.cs | 66 +++++ .../MinimalApi/Extensions.cs | 54 ++++ .../MinimalApi/HttpResultsShims.cs | 237 +++++++++++++++ .../MinimalApi/SampleObjectEndpoints.cs | 166 +++++++++++ .../Models/Inputs/GetByIdInput.cs | 11 + .../Models/SampleObject.cs | 10 + .../Models/SampleObjectSearch.cs | 45 +++ ...tectedNumbers.Tests.EndToEnd.Shared.csproj | 32 +++ .../Repositories/SampleObjectRepository.cs | 122 ++++++++ .../Validators/Extensions.cs | 42 +++ .../Inputs/GetByIdInputValidator.cs | 22 ++ .../Validators/SampleObjectSearchValidator.cs | 25 ++ .../Validators/SampleObjectValidator.cs | 24 ++ .../Program.cs | 11 + .../Properties/launchSettings.json | 23 ++ ...Numbers.Tests.EndToEnd.WebApi.Net10.csproj | 19 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Program.cs | 11 + .../Properties/launchSettings.json | 23 ++ ...dNumbers.Tests.EndToEnd.WebApi.Net6.csproj | 19 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Program.cs | 11 + .../Properties/launchSettings.json | 23 ++ ...dNumbers.Tests.EndToEnd.WebApi.Net8.csproj | 19 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../Program.cs | 11 + .../Properties/launchSettings.json | 23 ++ ...dNumbers.Tests.EndToEnd.WebApi.Net9.csproj | 19 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../CheckAllEndpointBindingsTests.cs | 66 +++++ .../EndpointStack.cs | 32 +++ .../ProtectedNumbers.Tests.EndToEnd.csproj | 46 +++ .../ScenarioData.cs | 14 + .../StepBuilderExtensions.cs | 270 ++++++++++++++++++ tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs | 87 ++++++ ...ApiAccessorWithEndpointStackFixtureData.cs | 19 ++ .../WebApiProvider.cs | 171 +++++++++++ 58 files changed, 2467 insertions(+), 14 deletions(-) create mode 100644 src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bb8db7f..c588efb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,12 +4,14 @@ + - + + @@ -17,6 +19,11 @@ + + + + + @@ -25,15 +32,22 @@ + + + + + + + diff --git a/ProtectedNumbers.sln b/ProtectedNumbers.sln index f492bf2..55f6968 100644 --- a/ProtectedNumbers.sln +++ b/ProtectedNumbers.sln @@ -39,6 +39,30 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "03-samples", "03-samples", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Samples", "samples\ProtectedNumbers.Samples\ProtectedNumbers.Samples.csproj", "{49437ECB-B026-4059-B235-C828D7578A40}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01-unit", "01-unit", "{07399998-14DD-4670-BB5D-7DE7A0F5CAD9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02-end-to-end", "02-end-to-end", "{CF69D319-F7B4-4A0F-8931-7C926716CE53}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.Shared", "tst\ProtectedNumbers.Tests.EndToEnd.Shared\ProtectedNumbers.Tests.EndToEnd.Shared.csproj", "{3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net6", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net6\ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj", "{16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net8", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net8\ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj", "{4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net9", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net9\ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj", "{3F156108-2C1B-4840-868E-BB927A9EBBDC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net10", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net10\ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj", "{6597682B-5D0E-430D-A317-E63961443F89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.AppHost", "tst\ProtectedNumbers.Tests.EndToEnd.AppHost\ProtectedNumbers.Tests.EndToEnd.AppHost.csproj", "{B7F72066-5054-44DA-8839-D01A89844381}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.ServiceDefaults", "tst\ProtectedNumbers.Tests.EndToEnd.ServiceDefaults\ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj", "{AA7C47F8-5F0C-4440-A829-C7767E528BBD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd", "tst\ProtectedNumbers.Tests.EndToEnd\ProtectedNumbers.Tests.EndToEnd.csproj", "{E5CFD612-400B-46C4-90EE-2318C43BAB8F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01-aspire", "01-aspire", "{8B2CB553-CF4A-41DB-BFB2-6438150CBB16}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02-webapi", "02-webapi", "{DC5DF0BA-2F47-432B-941B-6A1DC0921E24}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +83,38 @@ Global {49437ECB-B026-4059-B235-C828D7578A40}.Debug|Any CPU.Build.0 = Debug|Any CPU {49437ECB-B026-4059-B235-C828D7578A40}.Release|Any CPU.ActiveCfg = Release|Any CPU {49437ECB-B026-4059-B235-C828D7578A40}.Release|Any CPU.Build.0 = Release|Any CPU + {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Release|Any CPU.Build.0 = Release|Any CPU + {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Release|Any CPU.Build.0 = Release|Any CPU + {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Release|Any CPU.Build.0 = Release|Any CPU + {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Release|Any CPU.Build.0 = Release|Any CPU + {6597682B-5D0E-430D-A317-E63961443F89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6597682B-5D0E-430D-A317-E63961443F89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6597682B-5D0E-430D-A317-E63961443F89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6597682B-5D0E-430D-A317-E63961443F89}.Release|Any CPU.Build.0 = Release|Any CPU + {B7F72066-5054-44DA-8839-D01A89844381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7F72066-5054-44DA-8839-D01A89844381}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7F72066-5054-44DA-8839-D01A89844381}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7F72066-5054-44DA-8839-D01A89844381}.Release|Any CPU.Build.0 = Release|Any CPU + {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Release|Any CPU.Build.0 = Release|Any CPU + {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -68,7 +124,19 @@ Global {54E9DA71-884C-47CB-8234-397722D49CBA} = {28E1E0C8-E214-4118-B592-AE443DB7DFD9} {DF741A65-666B-4814-9FF1-361E29BDCC75} = {CFD2F704-18DC-4F75-9F91-DD4A58559AAA} {66B0C8CE-E238-470F-ABF5-0896D5367564} = {499B92F7-0036-4B5C-AE09-BA6DE023EC76} - {029D532A-57A2-4E35-803E-A2071598C043} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7} {49437ECB-B026-4059-B235-C828D7578A40} = {FCDBD854-E336-4DAD-9893-0DCD353FCA21} + {07399998-14DD-4670-BB5D-7DE7A0F5CAD9} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7} + {029D532A-57A2-4E35-803E-A2071598C043} = {07399998-14DD-4670-BB5D-7DE7A0F5CAD9} + {CF69D319-F7B4-4A0F-8931-7C926716CE53} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7} + {E5CFD612-400B-46C4-90EE-2318C43BAB8F} = {CF69D319-F7B4-4A0F-8931-7C926716CE53} + {8B2CB553-CF4A-41DB-BFB2-6438150CBB16} = {CF69D319-F7B4-4A0F-8931-7C926716CE53} + {B7F72066-5054-44DA-8839-D01A89844381} = {8B2CB553-CF4A-41DB-BFB2-6438150CBB16} + {AA7C47F8-5F0C-4440-A829-C7767E528BBD} = {8B2CB553-CF4A-41DB-BFB2-6438150CBB16} + {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} = {CF69D319-F7B4-4A0F-8931-7C926716CE53} + {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} + {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} + {3F156108-2C1B-4840-868E-BB927A9EBBDC} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} + {6597682B-5D0E-430D-A317-E63961443F89} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} + {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} EndGlobalSection EndGlobal diff --git a/build/Build.cs b/build/Build.cs index 9f71717..8891c47 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -204,18 +204,21 @@ class Build : NukeBuild .OnlyWhenDynamic(() => !SkipTests) .Executes(() => { - string dataCollector = EnableCoverage ? "Code Coverage;Format=cobertura" : null; - - DotNetTest(_ => _ - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore() - .EnableNoBuild() - .When(TestResultsDirectory.DirectoryExists(), _ => _ - .SetResultsDirectory(TestResultsDirectory) - ) - .When(!string.IsNullOrEmpty(dataCollector), _ => _ - .SetDataCollector(dataCollector) + string dataCollector = EnableCoverage ? "XPlat Code Coverage" : null; // coverlet.collector + + DotNetTest(_ => _ + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore() + .EnableNoBuild() + .When(TestResultsDirectory.DirectoryExists(), _ => _.SetResultsDirectory(TestResultsDirectory)) + .When(!string.IsNullOrEmpty(dataCollector), t => t + .SetDataCollector(dataCollector) + .SetProcessAdditionalArguments( + "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura", + "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include=\"[ProtectedNumbers]*\"", + "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude=\"[*.Tests]*\"" + ) ) ); diff --git a/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs b/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs new file mode 100644 index 0000000..9684d9e --- /dev/null +++ b/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs @@ -0,0 +1,24 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#if NET6_0 +namespace ProtectedNumbers.Internal; + +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +using ProtectedNumbers; + +/// +/// Disables child validation for the value object on .NET 6 MVC, +/// preventing the validator from invoking its getters during traversal. +/// +internal sealed class DisableChildValidationForProtectedNumber : IValidationMetadataProvider +{ + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + if (context.Key.ModelType == typeof(ProtectedNumber)) + { + context.ValidationMetadata.ValidateChildren = false; + } + } +} +#endif diff --git a/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs b/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs index 77c38be..f319743 100644 --- a/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs +++ b/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs @@ -53,5 +53,11 @@ public void Configure(Microsoft.AspNetCore.Mvc.MvcOptions options) { options.ModelBinderProviders.Add(new ProtectedNumberModelBinderProvider()); } + +#if NET6_0 + // Workaround for .NET 6 MVC validation eagerly traversing value objects and invoking getters. + // Suppress child validation for ProtectedNumber so MVC doesn't access Value/ProtectedValue during validation. + options.ModelMetadataDetailsProviders.Add(new DisableChildValidationForProtectedNumber()); +#endif } } diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs new file mode 100644 index 0000000..1e7af9a --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs @@ -0,0 +1,10 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("webapi-dotnet6"); +builder.AddProject("webapi-dotnet8"); +builder.AddProject("webapi-dotnet9"); +builder.AddProject("webapi-dotnet10"); + +builder.Build().Run(); diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..7327a5a --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17241;http://localhost:15273", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21076", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23075", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22080" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15273", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19014", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18058", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20043" + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj new file mode 100644 index 0000000..0391a4d --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + false + ProtectedNumbers.Tests.EndToEnd + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..c7abe93 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs @@ -0,0 +1,129 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj new file mode 100644 index 0000000..8a40fd9 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj @@ -0,0 +1,23 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs new file mode 100644 index 0000000..12c0838 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs @@ -0,0 +1,100 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Controllers; + +#if NET6_0 +using FluentValidation; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators; +#endif + +using Microsoft.AspNetCore.Mvc; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; + +[ApiController] +[Route("mvc/samples-objects")] +public class SampleObjectController : ControllerBase +{ + [HttpGet("")] + public IActionResult GetAll([FromServices] SampleObjectRepository repository) + { + IEnumerable allSampleObjects = repository.GetAll(); + + return Ok(allSampleObjects); + } + + [HttpGet("{id}")] + public IActionResult GetById( + [FromServices] SampleObjectRepository repository, + [FromRoute] ProtectedNumber id) + { + SampleObject? sampleObject = repository.GetById(id); + + if (sampleObject == null) + { + return NotFound(); + } + + return Ok(sampleObject); + } + + [HttpPut("")] + public IActionResult Save([FromServices] SampleObjectRepository repository, +#if NET6_0 + [FromServices] IValidator validator, +#endif + [FromBody] SampleObject sampleObject) + { +#if NET6_0 + // NOTE: Manual validation since FluentValidation AutoValidation is not available for MVC in .NET 6 + var validationResult = validator.Validate(sampleObject); + if (!validationResult.IsValid) + { + ValidationProblemDetails validationProblemDetails = validationResult.ToValidationProblemDetails(); + return BadRequest(validationProblemDetails); + } +#endif + + ProtectedNumber? id = sampleObject.Id; + SampleObject? saved = repository.Save(id, s => + { + s.Name = sampleObject.Name; + }); + + if (saved is null) + { + return NotFound(); + } + + return Ok(saved); + } + + [HttpGet("search")] + public IActionResult Search([FromServices] SampleObjectRepository repository, + [FromQuery] ProtectedNumber? id, [FromQuery] ProtectedNumber[]? ids) + { + SampleObjectSearch search = new() + { + Id = id, + Ids = ids, + }; + + IEnumerable result = repository.Search(search); + + return Ok(result); + } + + [HttpGet("search/object")] + public IActionResult SearchByObject([FromServices] SampleObjectRepository repository, + #if NET6_0 + [FromQuery] + #endif + SampleObjectSearch search) + { + IEnumerable result = repository.Search(search); + + return Ok(result); + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs new file mode 100644 index 0000000..98d0951 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs @@ -0,0 +1,47 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects; + +using FastEndpoints; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs; + +public class GetByIdEndpoint : Endpoint +{ + public GetByIdEndpoint(SampleObjectRepository repository) + { + Repository = repository; + } + + private SampleObjectRepository Repository { get; } + + public override void Configure() + { + AllowAnonymous(); + Validator(); + Get("/samples-objects/{id}"); + } + + public override Task HandleAsync(GetByIdInput input, CancellationToken cancellationToken) + { + SampleObject? sampleObject = Repository.GetById(input.Id.GetValueOrDefault(ProtectedNumber.Empty)); + + if (sampleObject == null) + { +#if NET6_0 + return SendNotFoundAsync(cancellationToken); +#elif NET8_0_OR_GREATER + return Send.NotFoundAsync(cancellationToken); +#endif + } + +#if NET6_0 + return SendOkAsync(sampleObject, cancellationToken); +#elif NET8_0_OR_GREATER + return Send.OkAsync(sampleObject, cancellationToken); +#endif + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs new file mode 100644 index 0000000..0da2e2c --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs @@ -0,0 +1,35 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects; + +using FastEndpoints; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; + +public class GetAllEndpoint : EndpointWithoutRequest> +{ + public GetAllEndpoint(SampleObjectRepository repository) + { + Repository = repository; + } + + private SampleObjectRepository Repository { get; } + + public override void Configure() + { + AllowAnonymous(); + Get("/samples-objects"); + } + + public override Task HandleAsync(CancellationToken cancellationToken) + { + IEnumerable all = Repository.GetAll(); + +#if NET6_0 + return SendOkAsync(all, cancellationToken); +#elif NET8_0_OR_GREATER + return Send.OkAsync(all, cancellationToken); +#endif + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs new file mode 100644 index 0000000..4602216 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs @@ -0,0 +1,49 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects; + +using FastEndpoints; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +public class SaveEndpoint : Endpoint +{ + public SaveEndpoint(SampleObjectRepository repository) + { + Repository = repository; + } + + private SampleObjectRepository Repository { get; } + + public override void Configure() + { + AllowAnonymous(); + Validator(); + Put("/samples-objects"); + } + + public override Task HandleAsync(SampleObject input, CancellationToken cancellationToken) + { + SampleObject? saved = Repository.Save(input.Id, s => + { + s.Name = input.Name; + }); + + if (saved == null) + { +#if NET6_0 + return SendNotFoundAsync(cancellationToken); +#elif NET8_0_OR_GREATER + return Send.NotFoundAsync(cancellationToken); +#endif + } + +#if NET6_0 + return SendOkAsync(saved, cancellationToken); +#elif NET8_0_OR_GREATER + return Send.OkAsync(saved, cancellationToken); +#endif + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs new file mode 100644 index 0000000..de74e53 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs @@ -0,0 +1,37 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects; + +using FastEndpoints; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +public class SearchEndpoint : Endpoint> +{ + public SearchEndpoint(SampleObjectRepository repository) + { + Repository = repository; + } + + private SampleObjectRepository Repository { get; } + + public override void Configure() + { + AllowAnonymous(); + Validator(); + Get("/samples-objects/search", "/samples-objects/search/object"); + } + + public override Task HandleAsync(SampleObjectSearch input, CancellationToken cancellationToken) + { + IEnumerable result = Repository.Search(input); + +#if NET6_0 + return SendOkAsync(result, cancellationToken); +#elif NET8_0_OR_GREATER + return Send.OkAsync(result, cancellationToken); +#endif + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs new file mode 100644 index 0000000..57370a9 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared; + +using FastEndpoints; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; + +using ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +#if NET8_0_OR_GREATER +using SharpGrip.FluentValidation.AutoValidation.Endpoints.Configuration; +using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Configuration; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Enums; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; +#endif + +public static class Extensions +{ + public static void AddProtectedNumbersEndToEnd(this WebApplicationBuilder builder, string webAppName) + { + // Required for ProtectedNumbers to work correctly + builder.Services.AddHttpContextAccessor(); + builder.Services.AddDataProtection(opts => + { + opts.ApplicationDiscriminator = $"ProtectedNumbers.Tests.EndToEnd.{webAppName}"; + }) + // .PersistKeysToFileSystem(new DirectoryInfo("./DataProtectionKeys")) + ; + + builder.Services.AddProtectedNumbers(); + + builder.Services.AddControllers(); + builder.Services.AddFastEndpoints(); + +#if NET6_0 +#elif NET8_0_OR_GREATER + builder.Services.AddFluentValidationAutoValidation((AutoValidationMvcConfiguration cfg) => + { + cfg.ValidationStrategy = ValidationStrategy.All; + }); + + builder.Services.AddFluentValidationAutoValidation((AutoValidationEndpointsConfiguration _) => + { + }); +#endif + + builder.Services.AddSingleton(); + builder.Services.AddValidators(); + } + + public static void UseProtectedNumbersEndToEnd(this WebApplication app) + { + app.RegisterMinimalApiEndpoints(); + app.MapControllers(); + app.UseFastEndpoints(cfg => + { + cfg.Endpoints.RoutePrefix = "fast-endpoints"; + }); + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs new file mode 100644 index 0000000..2cbed44 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +#if NET6_0 +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +using ProtectedNumbers.Protection; + +#elif NET8_0_OR_GREATER +using Microsoft.AspNetCore.Routing; +#endif + +public static class Extensions +{ + public static void RegisterMinimalApiEndpoints(this WebApplication app) + { + const string minimalApiPrefix = "/minimal-api"; +#if NET6_0 + app.RegisterSampleObjectEndpoints(minimalApiPrefix); +#elif NET8_0_OR_GREATER + RouteGroupBuilder minimalApi = app.MapGroup(minimalApiPrefix); + + minimalApi.RegisterSampleObjectEndpoints(); +#endif + } + +#if NET6_0 + public static ProtectedNumber[]? BindProtectedNumberCollection(this HttpContext httpContext, string parameterName) + { + if (!httpContext.Request.Query.TryGetValue(parameterName, out StringValues stringValues)) + { + return null; + } + + IApplicationDataProtector? protector = httpContext.RequestServices.GetService(); + string?[] protectedNumberRaws = stringValues.ToArray(); + + // HACK: Minimal api on .net 6 don't handle correctly collection binding: we must unprotect manually collections only on .net 6 (it's done automatically/naturally on newer .net versions) + ProtectedNumber[]? protectedNumbers = + protectedNumberRaws.Select(s => ProtectedNumber.From(null, s)) // capture protected string + .Select(pn => protector != null && protector.TryUnprotect(pn, out var unp) // attempt unprotect + ? unp!.Value + : pn) // keep as-is if unprotect fails + .ToArray(); + + return protectedNumbers; + } +#endif + +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs new file mode 100644 index 0000000..5bae22d --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs @@ -0,0 +1,237 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +#if NET6_0 +#pragma warning disable CS9113 // Parameter is unread - we may only need to store some values for parity +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +// This namespace must match newer ASP.NET Core typed results +namespace Microsoft.AspNetCore.Http.HttpResults +{ + // --------- Primitive typed results --------- + public readonly struct Ok : IResult + { + private readonly T _value; + public Ok(T value) => _value = value; + public Task ExecuteAsync(HttpContext httpContext) => Results.Ok(_value).ExecuteAsync(httpContext); + } + + public readonly struct NotFound : IResult + { + public Task ExecuteAsync(HttpContext httpContext) => Results.NotFound().ExecuteAsync(httpContext); + } + + public readonly struct NoContent : IResult + { + public Task ExecuteAsync(HttpContext httpContext) => Results.NoContent().ExecuteAsync(httpContext); + } + + public readonly struct BadRequest : IResult + { + public BadRequest(){} + public Task ExecuteAsync(HttpContext httpContext) + => Results.BadRequest().ExecuteAsync(httpContext); + } + + public readonly struct BadRequest : IResult + { + private readonly T? _error; + public BadRequest(T? error = default) => _error = error; + public Task ExecuteAsync(HttpContext httpContext) + => (_error is null ? Results.BadRequest() : Results.BadRequest(_error)).ExecuteAsync(httpContext); + } + + public readonly struct Created : IResult + { + private readonly string _uri; + private readonly T _value; + public Created(string uri, T value) { _uri = uri; _value = value; } + public Task ExecuteAsync(HttpContext httpContext) + => Results.Created(_uri, _value).ExecuteAsync(httpContext); + } + + public readonly struct CreatedAtRoute : IResult + { + private readonly string? _routeName; + private readonly object? _routeValues; + private readonly T _value; + public CreatedAtRoute(string? routeName, object? routeValues, T value) + { _routeName = routeName; _routeValues = routeValues; _value = value; } + public Task ExecuteAsync(HttpContext httpContext) + => Results.CreatedAtRoute(_routeName, _routeValues, _value).ExecuteAsync(httpContext); + } + + public readonly struct ValidationProblem : IResult + { + private readonly IDictionary _errors; + private readonly int? _statusCode; + private readonly string? _title; + private readonly string? _detail; + private readonly string? _instance; + private readonly string? _type; + private readonly IDictionary? _extensions; + + public ValidationProblem( + IDictionary errors, + int? statusCode = null, + string? title = null, + string? type = null, + string? detail = null, + string? instance = null, + IDictionary? extensions = null) + { + _errors = errors; + _statusCode = statusCode; + _title = title; _detail = detail; _instance = instance; _type = type; + _extensions = extensions; + } + + public Task ExecuteAsync(HttpContext httpContext) + => Results.ValidationProblem(_errors, + statusCode: _statusCode, + title: _title, + type: _type, + detail: _detail, + instance: _instance, + extensions: _extensions) + .ExecuteAsync(httpContext); + } + + public readonly struct ProblemHttpResult : IResult + { + private readonly string? _detail; + private readonly string? _instance; + private readonly int? _statusCode; + private readonly string? _title; + private readonly string? _type; + private readonly IDictionary? _extensions; + + public ProblemHttpResult( + string? detail = null, + string? instance = null, + int? statusCode = null, + string? title = null, + string? type = null, + IDictionary? extensions = null) + { + _detail = detail; _instance = instance; _statusCode = statusCode; _title = title; _type = type; _extensions = extensions; + } + + public Task ExecuteAsync(HttpContext httpContext) + => Results.Problem( + detail: _detail, + instance: _instance, + statusCode: _statusCode, + title: _title, + type: _type, + extensions: _extensions) + .ExecuteAsync(httpContext); + } + + // --------- Union wrappers (Results) --------- + public readonly struct Results : IResult + where T1 : struct, IResult + where T2 : struct, IResult + { + private readonly IResult _inner; + private Results(IResult inner) => _inner = inner; + public static implicit operator Results(T1 value) => new(value); + public static implicit operator Results(T2 value) => new(value); + public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext); + } + + public readonly struct Results : IResult + where T1 : struct, IResult + where T2 : struct, IResult + where T3 : struct, IResult + { + private readonly IResult _inner; + private Results(IResult inner) => _inner = inner; + public static implicit operator Results(T1 value) => new(value); + public static implicit operator Results(T2 value) => new(value); + public static implicit operator Results(T3 value) => new(value); + public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext); + } + + public readonly struct Results : IResult + where T1 : struct, IResult + where T2 : struct, IResult + where T3 : struct, IResult + where T4 : struct, IResult + { + private readonly IResult _inner; + private Results(IResult inner) => _inner = inner; + public static implicit operator Results(T1 value) => new(value); + public static implicit operator Results(T2 value) => new(value); + public static implicit operator Results(T3 value) => new(value); + public static implicit operator Results(T4 value) => new(value); + public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext); + } + + public readonly struct Results : IResult + where T1 : struct, IResult + where T2 : struct, IResult + where T3 : struct, IResult + where T4 : struct, IResult + where T5 : struct, IResult + { + private readonly IResult _inner; + private Results(IResult inner) => _inner = inner; + public static implicit operator Results(T1 value) => new(value); + public static implicit operator Results(T2 value) => new(value); + public static implicit operator Results(T3 value) => new(value); + public static implicit operator Results(T4 value) => new(value); + public static implicit operator Results(T5 value) => new(value); + public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext); + } + + // --------- Factory methods (TypedResults) --------- + public static class TypedResults + { + // 200 + public static Ok Ok(T value) => new(value); + + // 201 + public static Created Created(string uri, T value) => new(uri, value); + public static CreatedAtRoute CreatedAtRoute(string? routeName, object? routeValues, T value) + => new(routeName, routeValues, value); + + // 204 + public static NoContent NoContent() => new(); + + // 400 + public static BadRequest BadRequest() => new(); + public static BadRequest BadRequest(T? error) => new(error); + + // 404 + public static NotFound NotFound() => new(); + + // 422 / 400-like (depends on config) + public static ValidationProblem ValidationProblem( + IDictionary errors, + int? statusCode = null, + string? title = null, + string? type = null, + string? detail = null, + string? instance = null, + IDictionary? extensions = null) + => new(errors, statusCode, title, type, detail, instance, extensions); + + // RFC7807 Problem + public static ProblemHttpResult Problem( + string? detail = null, + string? instance = null, + int? statusCode = null, + string? title = null, + string? type = null, + IDictionary? extensions = null) + => new(detail, instance, statusCode, title, type, extensions); + } +} +#pragma warning restore CS9113 +#endif diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs new file mode 100644 index 0000000..1631513 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs @@ -0,0 +1,166 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi; + +using FluentValidation; +using FluentValidation.Results; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.Routing; + +using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions; +#endif + +public static class SampleObjectEndpoints +{ +#if NET6_0 + public static void RegisterSampleObjectEndpoints(this WebApplication app, string prefix) + { + string groupPath = $"{prefix}/samples-objects"; + + app.MapGet($"{groupPath}/", GetAll); + app.MapGet(groupPath + "/{id}", GetById); + app.MapGet($"{groupPath}/search", Search); + app.MapGet($"{groupPath}/search/object", SearchByObject); + app.MapPut($"{groupPath}/", Save); + } +#elif NET8_0_OR_GREATER + public static void RegisterSampleObjectEndpoints(this RouteGroupBuilder minimalApi) + { + RouteGroupBuilder sampleObjectEndpoints = minimalApi.MapGroup("/samples-objects") + .AddFluentValidationAutoValidation() + ; + + sampleObjectEndpoints.MapGet("/", GetAll); + sampleObjectEndpoints.MapGet("/{id}", GetById); + sampleObjectEndpoints.MapGet("/search", Search); + sampleObjectEndpoints.MapGet("/search/object", SearchByObject); + sampleObjectEndpoints.MapPut("/", Save); + } +#endif + + private static Ok> GetAll(SampleObjectRepository repository) + { + IEnumerable all = repository.GetAll(); + + return TypedResults.Ok(all); + } + + private static Results, BadRequest, NotFound> GetById( + SampleObjectRepository repository, + ProtectedNumber id) + { + if (!id.HaveProtectedValueAndValue()) + { + return TypedResults.BadRequest(); + } + + SampleObject? item = repository.GetById(id); + + if (item is null) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(item); + } + + private static Results, NotFound, BadRequest> Save( + SampleObjectRepository repository, +#if NET6_0 + [FromServices] IValidator validator, +#endif + [FromBody] SampleObject sampleObject) + { +#if NET6_0 + // NOTE: Manual validation since FluentValidation AutoValidation is not available for Minimal APIs in .NET 6 + var validationResult = validator.Validate(sampleObject); + if (!validationResult.IsValid) + { + ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails(); + return TypedResults.BadRequest(problemDetails); + } +#endif + + ProtectedNumber? id = sampleObject.Id; + SampleObject? saved = repository.Save(id, s => + { + s.Name = sampleObject.Name; + }); + + if (saved is null) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(saved); + } + + private static Results>, BadRequest> Search( +#if NET6_0 + HttpContext httpContext, +#endif + [FromServices] SampleObjectRepository repository, + [FromServices] IValidator validator, + [FromQuery] ProtectedNumber? id +#if NET8_0_OR_GREATER + , [FromQuery] ProtectedNumber[]? ids +#endif + ) + { +#if NET6_0 + ProtectedNumber[]? ids = httpContext.BindProtectedNumberCollection("ids"); +#endif + SampleObjectSearch search = new() + { + Id = id, + Ids = ids, + }; + + // NOTE: Manual validation since independant parameters binding does not support FluentValidation AutoValidation + ValidationResult? validationResult = validator.Validate(search); + if (!validationResult.IsValid) + { + ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails(); + + return TypedResults.BadRequest(problemDetails); + } + + IEnumerable result = repository.Search(search); + return TypedResults.Ok(result); + } + + private static Results>, BadRequest> SearchByObject( + [FromServices] SampleObjectRepository repository, +#if NET6_0 + [FromServices] IValidator validator, +#endif +#if NET8_0_OR_GREATER + [AsParameters] +#endif + SampleObjectSearch search) + { +#if NET6_0 + // NOTE: Manual validation since FluentValidation AutoValidation is not available for Minimal APIs in .NET 6 + var validationResult = validator.Validate(search); + if (!validationResult.IsValid) + { + ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails(); + return TypedResults.BadRequest(problemDetails); + } +#endif + + IEnumerable result = repository.Search(search); + + return TypedResults.Ok(result); + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs new file mode 100644 index 0000000..e1e9b13 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs @@ -0,0 +1,11 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs; + +using Microsoft.AspNetCore.Mvc; + +public class GetByIdInput +{ + [FromRoute] + public ProtectedNumber? Id { get; set; } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs new file mode 100644 index 0000000..81adb65 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs @@ -0,0 +1,10 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models; + +public class SampleObject +{ + public ProtectedNumber? Id { get; set; } + + public string? Name { get; set; } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs new file mode 100644 index 0000000..2b8ec43 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs @@ -0,0 +1,45 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models; + +using Microsoft.AspNetCore.Mvc; + +#if NET6_0 +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ProtectedNumbers.Protection; +using ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi; +#endif + + +public class SampleObjectSearch +{ + [FromQuery] + public ProtectedNumber? Id { get; set; } + + [FromQuery] + public ProtectedNumber[]? Ids { get; set; } + +#if NET6_0 + // Minimal APIs in .NET 6 will use this instead of inferring body binding + public static ValueTask BindAsync(HttpContext httpContext) + { + // Optional: resolve services if you need to unprotect ids + IApplicationDataProtector? protector = httpContext.RequestServices.GetService(); + ProtectedNumber? id = null; + + if (httpContext.Request.Query.TryGetValue("id", out var idVals) && idVals.Count > 0) + { + id = ProtectedNumber.From(null, idVals[0]); + if (protector is not null && protector.TryUnprotect(id.Value, out var unp)) + { + id = unp!.Value; + } + } + + ProtectedNumber[]? ids = httpContext.BindProtectedNumberCollection("ids"); + + return ValueTask.FromResult(new SampleObjectSearch { Id = id, Ids = ids }); + } +#endif +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj new file mode 100644 index 0000000..8736abb --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj @@ -0,0 +1,32 @@ + + + + net6.0;net8.0;net9.0;net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs new file mode 100644 index 0000000..30a1258 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs @@ -0,0 +1,122 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Repositories; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; + +public class SampleObjectRepository +{ + public SampleObjectRepository() + { + Db = + [ + new SampleObject + { + Id = ProtectedNumber.From(3268149285222967951L, null), + Name = "3268149285222967951" + }, + new SampleObject + { + Id = ProtectedNumber.From(4370645650355927116L, null), + Name = "4370645650355927116" + }, + new SampleObject + { + Id = ProtectedNumber.From(2849208538696462814L, null), + Name = "2849208538696462814" + }, + new SampleObject + { + Id = ProtectedNumber.From(2737488684378130375L, null), + Name = "2737488684378130375" + }, + new SampleObject + { + Id = ProtectedNumber.From(3165521510852862138L, null), + Name = "3165521510852862138" + }, + new SampleObject + { + Id = ProtectedNumber.From(3367344455419562669L, null), + Name = "3367344455419562669" + }, + new SampleObject + { + Id = ProtectedNumber.From(4046037197419659517L, null), + Name = "4046037197419659517" + }, + new SampleObject + { + Id = ProtectedNumber.From(4605951185251650790L, null), + Name = "4605951185251650790" + }, + new SampleObject + { + Id = ProtectedNumber.From(2831023411971416963L, null), + Name = "2831023411971416963" + }, + new SampleObject + { + Id = ProtectedNumber.From(4018378694012108869L, null), + Name = "4018378694012108869" + }, + ]; + } + + private List Db { get; } + + public IEnumerable GetAll() => Db; + + public SampleObject? GetById(ProtectedNumber protectedNumber) + { + SampleObject? sampleObject = Db.FirstOrDefault(i => protectedNumber.Equals(i.Id)); + + return sampleObject; + } + + public IEnumerable Search(SampleObjectSearch search) + { + IQueryable query = Db.AsQueryable(); + + if (search.Id.HasValue) + { + query = query.Where(s => s.Id.HasValue && s.Id.Value.Equals(search.Id.Value)); + } + + if (search.Ids is { Length: > 0 }) + { + query = query.Where(s => s.Id.HasValue && search.Ids.Any(id => id.Equals(s.Id.Value))); + } + + List results = query.ToList(); + + return results; + } + + public SampleObject? Save(ProtectedNumber? id, Action changeAction) + { + SampleObject? sampleObject = null; + + if (id.HasValue) + { + sampleObject = GetById(id.Value); + } + else + { + sampleObject = new() + { + Id = ProtectedNumber.From(Random.Shared.NextInt64(100000000000000000L, 999999999999999999L), null) + }; + Db.Add(sampleObject); + } + + if (sampleObject == null) + { + return null; + } + + changeAction.Invoke(sampleObject); + + return sampleObject; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs new file mode 100644 index 0000000..8bf5adb --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +using FluentValidation; +using FluentValidation.Results; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; +using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs; +using ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs; + +public static class Extensions +{ + public static void AddValidators(this IServiceCollection services) + { + services.AddScoped, SampleObjectValidator>(); + services.AddScoped, SampleObjectSearchValidator>(); + + services.AddScoped, GetByIdInputValidator>(); + } + + public static bool HaveProtectedValueAndValue(this ProtectedNumber protectedNumber) + { + return protectedNumber.IsInitialized() && + protectedNumber is { HasProtectedValue: true, HasValue: true }; + } + + public static ValidationProblemDetails ToValidationProblemDetails(this ValidationResult validationResult) + { + ValidationProblemDetails validationProblemDetails = new(validationResult.ToDictionary()) + { + Status = StatusCodes.Status400BadRequest, + Title = "One or more validation errors occurred.", + }; + + return validationProblemDetails; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs new file mode 100644 index 0000000..95f29cf --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs; + +using FluentValidation; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs; + +public class GetByIdInputValidator : AbstractValidator +{ + public GetByIdInputValidator() + { + RuleFor(e => e.Id) + .NotNull() + ; + RuleFor(e => e.Id) + .Must(e => e.HasValue && e.Value.HaveProtectedValueAndValue()) + .When(e => e.Id.HasValue) + .WithMessage("id must be valid") + ; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs new file mode 100644 index 0000000..c612715 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs @@ -0,0 +1,25 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +using FluentValidation; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; + +public class SampleObjectSearchValidator : AbstractValidator +{ + public SampleObjectSearchValidator() + { + RuleFor(i => i.Id) + .Must(i => i.HasValue && i.Value.HaveProtectedValueAndValue()) + .When(i => i.Id.HasValue) + .WithMessage("id must be valid") + ; + + RuleForEach(i => i.Ids) + .Must(i => i.HaveProtectedValueAndValue()) + .When(i => i.Ids != null && i.Ids.Length > 0) + .WithMessage("ids must be valid") + ; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs new file mode 100644 index 0000000..e31ff5f --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs @@ -0,0 +1,24 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators; + +using FluentValidation; + +using ProtectedNumbers.Tests.EndToEnd.Shared.Models; + +public class SampleObjectValidator : AbstractValidator +{ + public SampleObjectValidator() + { + RuleFor(e => e.Id) + .Must(e => e.HasValue && e.Value.HaveProtectedValueAndValue()) + .When(e => e.Id.HasValue) + .WithMessage("id must be valid") + ; + + RuleFor(e => e.Name) + .NotEmpty() + .WithMessage("name is required") + ; + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs new file mode 100644 index 0000000..b48266f --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs @@ -0,0 +1,11 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddProtectedNumbersEndToEnd("WebApi.Net10"); + +WebApplication app = builder.Build(); + +app.UseProtectedNumbersEndToEnd(); +app.Run(); diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json new file mode 100644 index 0000000..402dfca --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:10000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:10001;http://localhost:10000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj new file mode 100644 index 0000000..c004194 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs new file mode 100644 index 0000000..4a32b35 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs @@ -0,0 +1,11 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +//builder.AddServiceDefaults(); +builder.AddProtectedNumbersEndToEnd("WebApi.Net6"); + +WebApplication app = builder.Build(); + +app.UseProtectedNumbersEndToEnd(); +app.Run(); diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json new file mode 100644 index 0000000..6c400e6 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:6000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:6001;http://localhost:6000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj new file mode 100644 index 0000000..0e83dd4 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + false + false + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs new file mode 100644 index 0000000..ce4b215 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs @@ -0,0 +1,11 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddProtectedNumbersEndToEnd("WebApi.Net8"); + +WebApplication app = builder.Build(); + +app.UseProtectedNumbersEndToEnd(); +app.Run(); diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json new file mode 100644 index 0000000..0e4767b --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:8001;http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj new file mode 100644 index 0000000..baa4ef2 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs new file mode 100644 index 0000000..2d7ebf9 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs @@ -0,0 +1,11 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddProtectedNumbersEndToEnd("WebApi.Net9"); + +WebApplication app = builder.Build(); + +app.UseProtectedNumbersEndToEnd(); +app.Run(); diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json new file mode 100644 index 0000000..7749f4e --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:9000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:9001;http://localhost:9000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj new file mode 100644 index 0000000..9e3ae26 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs b/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs new file mode 100644 index 0000000..1738ef8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +using ConfirmSteps.Steps.Http; + +using NUnit.Framework.Internal; + +using static StepBuilderExtensions; + +[TestFixtureSource(typeof(WebApiWithEndpointStackFixtureData), nameof(WebApiWithEndpointStackFixtureData.FixtureParams))] +public class CheckAllEndpointBindingsTests +{ + public CheckAllEndpointBindingsTests(WebApi webApi, EndpointStack endpointStack) + { + WebApi = webApi; + EndpointStack = endpointStack; + } + + private EndpointStack EndpointStack { get; } + + private WebApi WebApi { get; } + + [Test] + public async Task AllSteps_Should_Returns_Correctly() + { + // Arrange + HttpClient? httpClient = WebApiProvider.GetHttpClient(WebApi); + + if (httpClient == null) + { + throw new NUnitException(); + } + + Scenario scenario = + Scenario.New("[All-Endpoints-Returns-Correctly]") + .WithServices(s => s.AddExternalHttpClient(httpClient)) + .WithGlobals(b => b + .UseConst(STEP_PATH_PREFIX, EndpointStack.PathPrefix) + ) + .WithSteps(s => s + .GetAll() + .GetByProtectedId() + .SearchByProtectedId() + .SearchObjectByProtectedId() + .SaveByProtectedId() + .GetByUnprotectedId() + .SearchByUnprotectedId() + .SearchObjectByUnprotectedId() + .SaveByUnprotectedId() + ) + .Build() + ; + ScenarioData data = new(); + // Act + using CancellationTokenSource cts = new(); + ConfirmStepResult confirmResult = + await scenario.ConfirmSteps(data, cts.Token); + // Assert + confirmResult.ShouldSatisfyAllConditions($"should be a successful confirm result without exception thrown on [{WebApi.Name}-{EndpointStack.Name}]", + r => r.Status.ShouldBe(ConfirmStatus.Success), + r => r.Exception.ShouldBeNull(), + r => r.StepResults.ShouldAllBe(sr => sr.State == StepState.Done && sr.Status == ConfirmStatus.Success) + ); + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs b/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs new file mode 100644 index 0000000..d42bada --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs @@ -0,0 +1,32 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +public class EndpointStack +{ + private EndpointStack(string name, string pathPrefix) + { + Name = name; + PathPrefix = pathPrefix; + } + + public static EndpointStack FastEndpoints { get; } = new("FastEndpoints", "fast-endpoints"); + + public static EndpointStack MinimalApi { get; } = new("Minimal Api", "minimal-api"); + + public static EndpointStack Mvc { get; } = new("MVC", "mvc"); + + public static IEnumerable EnumerateStacks() + { + yield return Mvc; + yield return MinimalApi; + yield return FastEndpoints; + } + + public string Name { get; } + + public string PathPrefix { get; } + + /// + public override string ToString() => Name; +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj b/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj new file mode 100644 index 0000000..36b9d12 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj @@ -0,0 +1,46 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + PreserveNewest + PreserveNewest + + + + diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs b/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs new file mode 100644 index 0000000..1d632d8 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs @@ -0,0 +1,14 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +public class ScenarioData +{ + public string? DefaultName { get; set; } + + public string? DefaultProtectedId { get; set; } + + public string[]? Names { get; set; } + + public string[]? ProtectedIds { get; set; } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs new file mode 100644 index 0000000..b576209 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs @@ -0,0 +1,270 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace ProtectedNumbers.Tests.EndToEnd; + +using System.Text.Json; + +using JsonCons.JsonPath; + +using Microsoft.Net.Http.Headers; + +public static class StepBuilderExtensions +{ + public const string SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE = "{{" + SAMPLE_OBJECT_PROTECTED_ID + "}}"; + + public const string SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE = "{{" + SAMPLE_OBJECT_UNPROTECTED_ID + "}}"; + + public const string STEP_PATH_PREFIX = "PATH_PREFIX"; + + private const string SAMPLE_OBJECT_PROTECTED_ID = "SAMPLE_OBJECT_PROTECTED_ID"; + + private const string SAMPLE_OBJECT_UNPROTECTED_ID = "SAMPLE_OBJECT_UNPROTECTED_ID"; + + private const string SAMPLE_OBJECTS_PATH = "samples-objects"; + + private const string STEP_PATH_PREFIX_TEMPLATE = "{{" + STEP_PATH_PREFIX + "}}"; + + public static IStepBuilderAppender GetAll( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-All", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH) + .WithHeaders(h => h.AppendDefaultHeaders()), + step => step + .VerifyJson((response, stepContext) => + { + response.ShouldSatisfyAllConditions("should be an OK JSON response", + r => r.StatusCode.ShouldBe(HttpStatusCode.OK), + r => r.Response.ShouldNotBeNull() + ); + + JsonElement rootElement = response.Response!.RootElement; + + rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item", + e => e.ValueKind.ShouldBe(JsonValueKind.Array), + e => e.GetArrayLength().ShouldBeGreaterThan(0) + ); + + IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id"); + IList nameJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].name"); + + idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + nameJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + + // TODO: Should be in Extract instead of VerifyJson: need to add feature in ConfirmSteps.Net + stepContext.ScenarioContext.Data.ProtectedIds = idJsonElements + .Select(elt => elt.GetString()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray()!; + + stepContext.ScenarioContext.Data.DefaultProtectedId = stepContext.ScenarioContext.Data.ProtectedIds.FirstOrDefault(); + stepContext.ScenarioContext.Data.Names = nameJsonElements + .Select(elt => elt.GetString()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray()!; + + stepContext.ScenarioContext.Data.DefaultName = stepContext.ScenarioContext.Data.Names.FirstOrDefault(); + + stepContext.Vars[SAMPLE_OBJECT_PROTECTED_ID] = stepContext.ScenarioContext.Data.DefaultProtectedId ?? string.Empty; + stepContext.Vars[SAMPLE_OBJECT_UNPROTECTED_ID] = stepContext.ScenarioContext.Data.DefaultName ?? string.Empty; + }) + ); + + public static IStepBuilderAppender GetByProtectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-By-ProtectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE) + .WithHeaders(h => h.AppendDefaultHeaders()), + step => step + .VerifyJson((response, _) => + { + response.ShouldSatisfyAllConditions("should be an OK JSON response", + r => r.StatusCode.ShouldBe(HttpStatusCode.OK), + r => r.Response.ShouldNotBeNull() + ); + + JsonElement rootElement = response.Response!.RootElement; + + rootElement.ShouldSatisfyAllConditions("should be a JSON object", + e => e.ValueKind.ShouldBe(JsonValueKind.Object) + ); + + IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$.id"); + + idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + }) + ); + + public static IStepBuilderAppender GetByUnprotectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-By-UnprotectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE) + .WithHeaders(h => h.AppendDefaultHeaders()), + step => step + .Verify((response, _) => + { + response.ShouldSatisfyAllConditions("should be a BadRequest response", + r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest) + ); + }) + ); + + public static IStepBuilderAppender SaveByProtectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("PUT-Save-By-ProtectedId", + () => RequestBuilder.Put() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH) + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithBody(@$"{{ ""id"": ""{SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE}"", ""name"": ""E2E Update""}}"), + step => step + .VerifyJson((response, _) => + { + response.ShouldSatisfyAllConditions("should be an OK JSON response", + r => r.StatusCode.ShouldBe(HttpStatusCode.OK), + r => r.Response.ShouldNotBeNull() + ); + + JsonElement rootElement = response.Response!.RootElement; + + rootElement.ShouldSatisfyAllConditions("should be a JSON object", + e => e.ValueKind.ShouldBe(JsonValueKind.Object) + ); + + IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$.id"); + + idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + }) + ); + + public static IStepBuilderAppender SaveByUnprotectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("PUT-Save-By-UnprotectedId", + () => RequestBuilder.Put() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH) + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithBody(@$"{{ ""id"": ""{SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE}"", ""name"": ""E2E Update""}}"), + step => step + .Verify((response, _) => + { + response.ShouldSatisfyAllConditions("should be a BadRequest response", + r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest) + ); + }) + ); + + public static IStepBuilderAppender SearchByProtectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-Search-By-ProtectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search") + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithQueryString(q => q + .Append("id", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE) + .Append("ids", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE) + ), + step => step + .VerifyJson((response, _) => + { + response.ShouldSatisfyAllConditions("should be an OK JSON response", + r => r.StatusCode.ShouldBe(HttpStatusCode.OK), + r => r.Response.ShouldNotBeNull() + ); + + JsonElement rootElement = response.Response!.RootElement; + + rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item", + e => e.ValueKind.ShouldBe(JsonValueKind.Array), + e => e.GetArrayLength().ShouldBe(1) + ); + + IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id"); + + idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + }) + ); + + public static IStepBuilderAppender SearchByUnprotectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-Search-By-UnprotectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search") + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithQueryString(q => q + .Append("id", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE) + .Append("ids", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE) + ), + step => step + .Verify((response, _) => + { + response.ShouldSatisfyAllConditions("should be a BadRequest response", + r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest) + ); + }) + ); + + public static IStepBuilderAppender SearchObjectByProtectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-SearchObject-By-ProtectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search", "object") + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithQueryString(q => q + .Append("id", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE) + .Append("ids", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE) + ), + step => step + .VerifyJson((response, _) => + { + response.ShouldSatisfyAllConditions("should be an OK JSON response", + r => r.StatusCode.ShouldBe(HttpStatusCode.OK), + r => r.Response.ShouldNotBeNull() + ); + + JsonElement rootElement = response.Response!.RootElement; + + rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item", + e => e.ValueKind.ShouldBe(JsonValueKind.Array), + e => e.GetArrayLength().ShouldBe(1) + ); + + IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id"); + + idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string"); + }) + ); + + public static IStepBuilderAppender SearchObjectByUnprotectedId( + this IStepBuilderAppender stepBuilderAppender) => + stepBuilderAppender + .HttpStep("GET-SearchObject-By-UnprotectedId", + () => RequestBuilder.Get() + .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search", "object") + .WithHeaders(h => h.AppendDefaultHeaders()) + .WithQueryString(q => q + .Append("id", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE) + .Append("ids", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE) + ), + step => step + .Verify((response, _) => + { + response.ShouldSatisfyAllConditions("should be a BadRequest response", + r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest) + ); + }) + ); + + private static HeaderBuilder AppendDefaultHeaders(this HeaderBuilder headerBuilder) => + headerBuilder + .Header(HeaderNames.ContentType, "application/json"); +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs new file mode 100644 index 0000000..049f6bc --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs @@ -0,0 +1,87 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +public class WebApi : IEquatable +{ + private WebApi(string name, string resourceName) + { + Name = name; + ResourceName = resourceName; + } + + public static IEnumerable EnumerateWebApi() + { + string[] dotnetVersions = [ + "dotnet6", + "dotnet8", + "dotnet9", + "dotnet10" + ]; + + foreach (string dotnetVersion in dotnetVersions) + { + string resourceName = $"webapi-{dotnetVersion}"; + WebApi webApi = new(dotnetVersion, resourceName); + + yield return webApi; + } + } + + public static bool operator ==(WebApi? left, WebApi? right) + { + return Equals(left, right); + } + + public static bool operator !=(WebApi? left, WebApi? right) + { + return !Equals(left, right); + } + + public string Name { get; } + + public string ResourceName { get; } + + /// + public bool Equals(WebApi? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name == other.Name; + } + + /// + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((WebApi)obj); + } + + /// + public override int GetHashCode() => Name.GetHashCode(); + + /// + public override string ToString() => Name; +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs new file mode 100644 index 0000000..5531d91 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs @@ -0,0 +1,19 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +using System.Collections; + +public static class WebApiWithEndpointStackFixtureData +{ + public static IEnumerable FixtureParams() + { + foreach (WebApi webApi in WebApi.EnumerateWebApi()) + { + foreach (EndpointStack endpointStack in EndpointStack.EnumerateStacks()) + { + yield return new TestFixtureData(webApi, endpointStack); + } + } + } +} diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs new file mode 100644 index 0000000..ada47b0 --- /dev/null +++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs @@ -0,0 +1,171 @@ +// Copyright (c) Grégory Célet. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +namespace ProtectedNumbers.Tests.EndToEnd; + +using Aspire.Hosting; + +using Microsoft.Extensions.Logging; + +using NUnit.Framework.Internal; + +[SetUpFixture] +public static class WebApiProvider +{ + private const int InfrastructureTimeout = 30_000; + + private static IDistributedApplicationTestingBuilder? AppHost { get; set; } + + private static DistributedApplication? DistributedApplication { get; set; } + + private static TimeSpan WaitingTimeout { get; } = TimeSpan.FromMilliseconds(InfrastructureTimeout - 500); + + private static Dictionary WebApiAccessors { get; } = new(); + + public static HttpClient? GetHttpClient(WebApi webApi) => WebApiAccessors.GetValueOrDefault(webApi); + + [OneTimeSetUp] + public static async Task OneTimeSetup() + { + using CancellationTokenSource cancellationTokenSource = new(); + + cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(InfrastructureTimeout)); + + CancellationToken cancellationToken = cancellationTokenSource.Token; + + await BuildAppHost(cancellationToken); + await BuildDistributedApplication(cancellationToken); + await BuildWebApiAccessors(cancellationToken); + } + + [OneTimeTearDown] + public static async Task OneTimeTearDown() + { + using CancellationTokenSource cancellationTokenSource = new(); + + cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(InfrastructureTimeout)); + + CancellationToken cancellationToken = cancellationTokenSource.Token; + + DisposeWebApiAccessors(); + await DisposeDistributedApplication(cancellationToken); + await DisposeAppHost(); + } + + private static async Task BuildAppHost(CancellationToken cancellationToken) + { + await DisposeAppHost(); + + AppHost = + await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); + + AppHost.Services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Debug); + // Override the logging filters from the app's configuration + logging.AddFilter(AppHost.Environment.ApplicationName, LogLevel.Debug); + logging.AddFilter("Aspire.", LogLevel.Debug); + }); + + AppHost.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + } + + private static async Task BuildDistributedApplication(CancellationToken cancellationToken) + { + if (AppHost == null) + { + throw new NUnitException($"Can't build '{nameof(DistributedApplication)}' when '{nameof(AppHost)}' is null"); + } + + await DisposeDistributedApplication(cancellationToken); + + DistributedApplication = await AppHost.BuildAsync(cancellationToken) + .WaitAsync(WaitingTimeout, cancellationToken) + ; + + await DistributedApplication.StartAsync(cancellationToken) + .WaitAsync(WaitingTimeout, cancellationToken) + ; + } + + private static async Task BuildWebApiAccessors(CancellationToken cancellationToken) + { + if (DistributedApplication == null) + { + throw new NUnitException($"Can't build '{nameof(WebApiAccessors)}' when '{nameof(DistributedApplication)}' is null"); + } + + DisposeWebApiAccessors(); + + Dictionary webApiAccessors = new(); + List resourceWaitTasks = new(); + + foreach (WebApi webApi in WebApi.EnumerateWebApi()) + { + HttpClient httpClient = DistributedApplication.CreateHttpClient(webApi.ResourceName); + Task resourceWaitTask = DistributedApplication.ResourceNotifications + .WaitForResourceHealthyAsync(webApi.ResourceName, cancellationToken) + .WaitAsync(WaitingTimeout, cancellationToken) + ; + + webApiAccessors[webApi] = httpClient; + resourceWaitTasks.Add(resourceWaitTask); + } + + if (resourceWaitTasks.Count > 0) + { + await Task.WhenAll(resourceWaitTasks); + } + + if (webApiAccessors.Count > 0) + { + foreach (KeyValuePair kvp in webApiAccessors) + { + WebApiAccessors[kvp.Key] = kvp.Value; + } + } + } + + private static async Task DisposeAppHost() + { + if (AppHost == null) + { + return; + } + + await AppHost.DisposeAsync(); + AppHost = null; + } + + private static async Task DisposeDistributedApplication(CancellationToken cancellationToken) + { + if (DistributedApplication == null) + { + return; + } + + await DistributedApplication.StopAsync(cancellationToken); + await DistributedApplication.DisposeAsync(); + DistributedApplication = null; + } + + private static void DisposeWebApiAccessors() + { + if (WebApiAccessors.Count == 0) + { + return; + } + + List webApiAccessors = WebApiAccessors.Values.ToList(); + + WebApiAccessors.Clear(); + + foreach (HttpClient httpClient in webApiAccessors) + { + httpClient.CancelPendingRequests(); + httpClient.Dispose(); + } + } +}