diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 5e793a7..35c474b 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -24,6 +24,7 @@ on: - "csharp-language/high-performance-memory-management/**" - "csharp-language/modern-patterns-result-pipeline/**" - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" + - "dotnet-8-essentials/configuration-secrets-environments/**" - ".github/workflows/build-samples.yml" pull_request: @@ -48,6 +49,7 @@ on: - "csharp-language/high-performance-memory-management/**" - "csharp-language/modern-patterns-result-pipeline/**" - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" + - "dotnet-8-essentials/configuration-secrets-environments/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1100,3 +1102,133 @@ jobs: )" test "${status}" = "400" + + test-config-precedence: + name: Test typed configuration precedence sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx + + - name: Build + run: > + dotnet build + dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx + --configuration Release + --no-build + + - name: Verify application has no direct packages + shell: bash + run: | + output="$( + dotnet list \ + dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/ConfigPrecedenceMinimal.csproj \ + package + )" + + printf '%s\n' "${output}" + + printf '%s\n' "${output}" | + grep --fixed-strings \ + "No packages were found" + + - name: Verify configuration precedence and redaction + shell: bash + env: + DOTNET_ENVIRONMENT: Development + ASPNETCORE_URLS: http://127.0.0.1:5097 + CFGPLAY_App__ApiKey: ci-demo-key-1234 + CFGPLAY_App__TimeoutSeconds: "30" + run: | + app_log="${RUNNER_TEMP}/config-precedence.log" + + dotnet run \ + --project dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/ConfigPrecedenceMinimal.csproj \ + --configuration Release \ + --no-build \ + -- \ + --App:TimeoutSeconds=40 \ + --Features:AdvancedSearch=false \ + >"${app_log}" 2>&1 & + + app_pid="$!" + + cleanup() { + kill "${app_pid}" 2>/dev/null || true + wait "${app_pid}" 2>/dev/null || true + } + + trap cleanup EXIT + + for attempt in $(seq 1 40); do + if curl \ + --fail \ + --silent \ + http://127.0.0.1:5097/ \ + >"${RUNNER_TEMP}/config-root.json"; then + break + fi + + if ! kill -0 "${app_pid}" 2>/dev/null; then + cat "${app_log}" + exit 1 + fi + + sleep 0.25 + done + + root="$( + cat "${RUNNER_TEMP}/config-root.json" + )" + + expected_root='{"sample":"typed-config-precedence","environment":"Development","serviceBaseUrl":"https://api.example.test","timeoutSeconds":40,"advancedSearch":false,"apiKeyConfigured":true}' + + if [[ "${root}" != "${expected_root}" ]]; then + printf 'Unexpected root response:\n%s\n' "${root}" + exit 1 + fi + + diagnostics="$( + curl \ + --fail \ + --silent \ + http://127.0.0.1:5097/dev/config + )" + + expected_diagnostics='{"environment":"Development","serviceBaseUrl":"https://api.example.test","timeoutSeconds":40,"advancedSearch":false,"apiKey":"[REDACTED]","apiKeyConfigured":true}' + + if [[ "${diagnostics}" != "${expected_diagnostics}" ]]; then + printf 'Unexpected diagnostics response:\n%s\n' "${diagnostics}" + exit 1 + fi + + if printf '%s\n%s\n' \ + "${root}" \ + "${diagnostics}" | + grep --quiet \ + --fixed-strings \ + "ci-demo-key-1234"; then + echo "Secret-like CI value leaked into an HTTP response." + exit 1 + fi diff --git a/README.md b/README.md index 443d6f0..b9e4732 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`csharp-language/high-performance-memory-management`](csharp-language/high-performance-memory-management/) | Focused .NET 10 UTF-8 ingestion sample demonstrating PipeReader framing, segmented ReadOnlySequence handling, span-based numeric parsing, bounded MemoryPool ownership, strict input validation, deterministic output, and tests | [High-Performance C#: Span, Memory, SIMD & Pipelines](https://www.dotnet-guide.com/tutorials/csharp-language/high-performance-memory-management/) | | [`csharp-language/modern-patterns-result-pipeline`](csharp-language/modern-patterns-result-pipeline/) | Focused .NET 10 console companion locked to C# 12.0, demonstrating a custom Result type, safe and diagnostic errors, Map/Bind/BindAsync composition, invariant text-import validation, short-circuit persistence, cancellation, deterministic output, and tests | [C# 12 Functional Patterns: Result Type, Error Handling & Composable Pipeline Testing](https://www.dotnet-guide.com/tutorials/csharp-language/modern-patterns-result-pipeline/) | | [`dotnet-8-essentials/background-jobs-hostedservice-queues`](dotnet-8-essentials/background-jobs-hostedservice-queues/) | Focused ASP.NET Core Minimal API demonstrating a bounded Channel queue, asynchronous backpressure, a BackgroundService consumer, fresh scoped handlers, safe in-memory job status, exception isolation, cancellation, and integration tests | [.NET 8 Background Jobs: IBackgroundTaskQueue, BackgroundService & Production-Ready Patterns](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/background-jobs-hostedservice-queues/) | +| [`dotnet-8-essentials/configuration-secrets-environments`](dotnet-8-essentials/configuration-secrets-environments/) | Focused .NET 10 Minimal API demonstrating layered configuration precedence, prefixed environment variables, command-line overrides, strongly typed options, startup validation, User Secrets metadata, a lightweight feature flag, and safe Development-only diagnostics | [.NET 8 Configuration & Secrets Management: Typed Options, User Secrets & Feature Flags](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/configuration-secrets-environments/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -133,27 +134,47 @@ tutorials/ | |-- TransactionalOutboxMinimal.Tests.csproj | `-- OutboxFlowTests.cs |-- dotnet-8-essentials/ -| `-- background-jobs-hostedservice-queues/ -| |-- BackgroundJobQueueMinimal.slnx +| |-- background-jobs-hostedservice-queues/ +| | |-- BackgroundJobQueueMinimal.slnx +| | |-- README.md +| | |-- src/ +| | | `-- BackgroundJobQueueMinimal/ +| | | |-- BackgroundJobQueueMinimal.csproj +| | | |-- Program.cs +| | | |-- Jobs/ +| | | | |-- BackgroundJobModels.cs +| | | | |-- IBackgroundJobQueue.cs +| | | | |-- BoundedBackgroundJobQueue.cs +| | | | |-- IJobTracker.cs +| | | | |-- InMemoryJobTracker.cs +| | | | `-- QueuedEmailWorker.cs +| | | `-- Services/ +| | | |-- IEmailJobHandler.cs +| | | `-- FakeEmailJobHandler.cs +| | `-- tests/ +| | `-- BackgroundJobQueueMinimal.Tests/ +| | |-- BackgroundJobQueueMinimal.Tests.csproj +| | `-- BackgroundJobQueueTests.cs +| `-- configuration-secrets-environments/ +| |-- ConfigPrecedenceMinimal.slnx | |-- README.md | |-- src/ -| | `-- BackgroundJobQueueMinimal/ -| | |-- BackgroundJobQueueMinimal.csproj +| | `-- ConfigPrecedenceMinimal/ +| | |-- ConfigPrecedenceMinimal.csproj | | |-- Program.cs -| | |-- Jobs/ -| | | |-- BackgroundJobModels.cs -| | | |-- IBackgroundJobQueue.cs -| | | |-- BoundedBackgroundJobQueue.cs -| | | |-- IJobTracker.cs -| | | |-- InMemoryJobTracker.cs -| | | `-- QueuedEmailWorker.cs -| | `-- Services/ -| | |-- IEmailJobHandler.cs -| | `-- FakeEmailJobHandler.cs +| | |-- appsettings.json +| | |-- appsettings.Development.json +| | |-- Diagnostics/ +| | | `-- SafeConfigSnapshot.cs +| | |-- Options/ +| | | |-- AppOptions.cs +| | | `-- FeatureFlagOptions.cs +| | `-- Validation/ +| | `-- AppOptionsValidator.cs | `-- tests/ -| `-- BackgroundJobQueueMinimal.Tests/ -| |-- BackgroundJobQueueMinimal.Tests.csproj -| `-- BackgroundJobQueueTests.cs +| `-- ConfigPrecedenceMinimal.Tests/ +| |-- ConfigPrecedenceMinimal.Tests.csproj +| `-- ConfigurationTests.cs |-- blazor/ | |-- create-interactive-ui-csharp-12/ | | |-- BlazorTodoMinimal.slnx diff --git a/dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx b/dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx new file mode 100644 index 0000000..3e407c7 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/ConfigPrecedenceMinimal.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet-8-essentials/configuration-secrets-environments/README.md b/dotnet-8-essentials/configuration-secrets-environments/README.md new file mode 100644 index 0000000..6da14a4 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/README.md @@ -0,0 +1,252 @@ +# Typed Configuration Precedence & Safe Diagnostics + +A focused ASP.NET Core Minimal API companion demonstrating layered +configuration, a project-specific environment-variable prefix, command-line +precedence, strongly typed options, startup validation, User Secrets metadata, +a lightweight feature flag, and an allowlisted Development-only diagnostics +endpoint that never returns the configured API-key value. + +## Full tutorial + +[.NET 8 Configuration & Secrets Management: Typed Options, User Secrets & Feature Flags](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/configuration-secrets-environments/) + +## Framework note + +The full tutorial is written for ASP.NET Core 8. + +This companion targets .NET 10 because that is the current DOTNET GUIDE sample +SDK. + +## Configuration flow + +```text +appsettings.json + -> appsettings.Development.json + -> User Secrets in Development + -> default environment variables + -> CFGPLAY_ prefixed environment variables + -> command-line arguments + -> validated options +``` + +The sample re-adds the command-line provider after `CFGPLAY_`, so explicit +command-line arguments remain the highest-priority application overrides. + +## Base values + +```text +TimeoutSeconds = 10 +AdvancedSearch = false +``` + +## Development overrides + +```text +TimeoutSeconds = 20 +AdvancedSearch = true +``` + +## Required API key + +`App:ApiKey` is intentionally empty in committed JSON. + +The app will fail startup until a higher-priority configuration provider +supplies a valid value. + +### Local Development with User Secrets + +From the application project folder: + +```powershell +dotnet user-secrets set ` + "App:ApiKey" ` + "local-demo-key-1234" +``` + +Secret Manager keeps development values outside the project tree and source +control, but the stored values are not encrypted and Secret Manager is not a +production secret store. + +Do not copy or commit `secrets.json`. + +## Environment-variable override + +The application adds: + +```csharp +AddEnvironmentVariables("CFGPLAY_") +``` + +The prefix is stripped. + +For example: + +```text +CFGPLAY_App__TimeoutSeconds +``` + +maps to: + +```text +App:TimeoutSeconds +``` + +Double underscore is the portable hierarchy separator for environment-variable +configuration. + +## Command-line precedence + +Run: + +```powershell +$env:DOTNET_ENVIRONMENT = "Development" +$env:CFGPLAY_App__ApiKey = "local-demo-key-1234" +$env:CFGPLAY_App__TimeoutSeconds = "30" + +dotnet run ` + --project .\src\ConfigPrecedenceMinimal\ConfigPrecedenceMinimal.csproj ` + --configuration Release ` + -- ` + --App:TimeoutSeconds=40 ` + --Features:AdvancedSearch=false +``` + +The final timeout is: + +```text +40 +``` + +because the command-line provider is deliberately added last. + +## Startup validation + +`AppOptions` is validated using: + +- DataAnnotations; +- a custom `IValidateOptions`; +- `ValidateOnStart`. + +The app rejects: + +- a missing or too-short API key; +- a malformed service URL; +- HTTP when `RequireHttps` is true; +- obvious placeholder API-key values. + +## Safe diagnostics + +The Development-only endpoint is: + +```text +GET /dev/config +``` + +It returns an explicit allowlist: + +```text +environment +serviceBaseUrl +timeoutSeconds +advancedSearch +apiKey +apiKeyConfigured +``` + +The API-key value is always: + +```text +[REDACTED] +``` + +The sample does not: + +- enumerate all of `IConfiguration`; +- expose `GetDebugView()` over HTTP; +- guess sensitivity from key names; +- serialize provider values. + +A generic redaction keyword list is not a security boundary. + +## Options interfaces + +The sample uses: + +```text +IOptions +IOptionsSnapshot +``` + +`IOptions` doesn't support reading changed configuration values after the +app has started. + +`IOptionsSnapshot` is scoped and recomputes options per scope when accessed. +It only reflects post-start configuration changes when the underlying provider +supports those changes. + +The full tutorial discusses `IOptionsMonitor` and named options. + +## Deliberately omitted + +- Azure Key Vault; +- AWS Secrets Manager; +- HashiCorp Vault; +- named options; +- live file reload; +- IOptionsMonitor callbacks; +- raw provider debug trees; +- external services; +- database configuration; +- SMTP configuration. + +## Restore, build, and test + +```powershell +dotnet restore ` + .\ConfigPrecedenceMinimal.slnx + +dotnet build ` + .\ConfigPrecedenceMinimal.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\ConfigPrecedenceMinimal.slnx ` + --configuration Release ` + --no-build +``` + +## Project structure + +```text +ConfigPrecedenceMinimal.slnx +README.md +src/ +`-- ConfigPrecedenceMinimal/ + |-- ConfigPrecedenceMinimal.csproj + |-- Program.cs + |-- appsettings.json + |-- appsettings.Development.json + |-- Diagnostics/ + | `-- SafeConfigSnapshot.cs + |-- Options/ + | |-- AppOptions.cs + | `-- FeatureFlagOptions.cs + `-- Validation/ + `-- AppOptionsValidator.cs +tests/ +`-- ConfigPrecedenceMinimal.Tests/ + |-- ConfigPrecedenceMinimal.Tests.csproj + `-- ConfigurationTests.cs +``` + +## Verification + +- Target framework: .NET 10 +- Application NuGet dependencies: none +- Test count: 8 +- External services: none +- Required secret committed to repo: none +- User Secrets: local Development only +- Development diagnostics: allowlisted and redacted +- Last reviewed: 2026-08-07 \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/ConfigPrecedenceMinimal.csproj b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/ConfigPrecedenceMinimal.csproj new file mode 100644 index 0000000..ffe3a3a --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/ConfigPrecedenceMinimal.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + true + + dotnet-guide-config-precedence-minimal + + + \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Diagnostics/SafeConfigSnapshot.cs b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Diagnostics/SafeConfigSnapshot.cs new file mode 100644 index 0000000..8df695b --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Diagnostics/SafeConfigSnapshot.cs @@ -0,0 +1,51 @@ +using ConfigPrecedenceMinimal.Options; + +namespace ConfigPrecedenceMinimal.Diagnostics; + +public sealed record SafeConfigSnapshot( + string Environment, + string ServiceBaseUrl, + int TimeoutSeconds, + bool AdvancedSearch, + string ApiKey, + bool ApiKeyConfigured) +{ + public static SafeConfigSnapshot Create( + IHostEnvironment environment, + AppOptions appOptions, + FeatureFlagOptions + featureOptions) + { + ArgumentNullException + .ThrowIfNull( + environment); + + ArgumentNullException + .ThrowIfNull( + appOptions); + + ArgumentNullException + .ThrowIfNull( + featureOptions); + + return new SafeConfigSnapshot( + Environment: + environment.EnvironmentName, + + ServiceBaseUrl: + appOptions.ServiceBaseUrl, + + TimeoutSeconds: + appOptions.TimeoutSeconds, + + AdvancedSearch: + featureOptions.AdvancedSearch, + + ApiKey: + "[REDACTED]", + + ApiKeyConfigured: + !string.IsNullOrWhiteSpace( + appOptions.ApiKey)); + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/AppOptions.cs b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/AppOptions.cs new file mode 100644 index 0000000..2d3e259 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/AppOptions.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace ConfigPrecedenceMinimal.Options; + +public sealed class AppOptions +{ + public const string SectionName = + "App"; + + [Required] + [Url] + public string ServiceBaseUrl + { + get; + init; + } = ""; + + [Range( + 1, + 120)] + public int TimeoutSeconds + { + get; + init; + } = 10; + + public bool RequireHttps + { + get; + init; + } = true; + + [Required] + [MinLength( + 12)] + public string ApiKey + { + get; + init; + } = ""; +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/FeatureFlagOptions.cs b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/FeatureFlagOptions.cs new file mode 100644 index 0000000..b790bfb --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Options/FeatureFlagOptions.cs @@ -0,0 +1,13 @@ +namespace ConfigPrecedenceMinimal.Options; + +public sealed class FeatureFlagOptions +{ + public const string SectionName = + "Features"; + + public bool AdvancedSearch + { + get; + init; + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Program.cs b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Program.cs new file mode 100644 index 0000000..268da19 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Program.cs @@ -0,0 +1,116 @@ +using ConfigPrecedenceMinimal.Diagnostics; +using ConfigPrecedenceMinimal.Options; +using ConfigPrecedenceMinimal.Validation; +using Microsoft.Extensions.Options; + +WebApplicationBuilder builder = + WebApplication.CreateBuilder( + args); + +// WebApplication.CreateBuilder already adds: +// appsettings.json +// appsettings.{Environment}.json +// User Secrets in Development when UserSecretsId exists +// unprefixed environment variables +// command-line arguments +// +// Add an application-specific environment-variable provider after the defaults. +// The CFGPLAY_ prefix is stripped before keys enter IConfiguration. +builder.Configuration + .AddEnvironmentVariables( + prefix: + "CFGPLAY_"); + +// Re-add command-line arguments so they remain the highest-priority +// application override after the custom prefixed provider. +builder.Configuration + .AddCommandLine( + args); + +builder.Services + .AddOptions() + .BindConfiguration( + AppOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services + .AddSingleton< + IValidateOptions, + AppOptionsValidator>(); + +builder.Services + .AddOptions() + .BindConfiguration( + FeatureFlagOptions.SectionName); + +WebApplication app = + builder.Build(); + +app.MapGet( + "/", + ( + IHostEnvironment environment, + IOptions + appOptions, + IOptionsSnapshot< + FeatureFlagOptions> + featureOptions) => + TypedResults.Ok( + new + { + sample = + "typed-config-precedence", + + environment = + environment + .EnvironmentName, + + serviceBaseUrl = + appOptions + .Value + .ServiceBaseUrl, + + timeoutSeconds = + appOptions + .Value + .TimeoutSeconds, + + advancedSearch = + featureOptions + .Value + .AdvancedSearch, + + apiKeyConfigured = + !string + .IsNullOrWhiteSpace( + appOptions + .Value + .ApiKey) + })); + +if (app.Environment + .IsDevelopment()) +{ + app.MapGet( + "/dev/config", + ( + IHostEnvironment + environment, + IOptions + appOptions, + IOptionsSnapshot< + FeatureFlagOptions> + featureOptions) => + TypedResults.Ok( + SafeConfigSnapshot + .Create( + environment, + appOptions.Value, + featureOptions + .Value))); +} + +app.Run(); + +public partial class Program; \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Validation/AppOptionsValidator.cs b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Validation/AppOptionsValidator.cs new file mode 100644 index 0000000..459aba0 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/Validation/AppOptionsValidator.cs @@ -0,0 +1,57 @@ +using ConfigPrecedenceMinimal.Options; +using Microsoft.Extensions.Options; + +namespace ConfigPrecedenceMinimal.Validation; + +public sealed class AppOptionsValidator : + IValidateOptions +{ + public ValidateOptionsResult Validate( + string? name, + AppOptions options) + { + ArgumentNullException + .ThrowIfNull( + options); + + List failures = + [ + ]; + + if (!Uri.TryCreate( + options.ServiceBaseUrl, + UriKind.Absolute, + out Uri? serviceUri) + || serviceUri.Scheme + is not ("http" or "https")) + { + failures.Add( + "App:ServiceBaseUrl must be an absolute HTTP or HTTPS URI."); + } + else if (options.RequireHttps + && serviceUri.Scheme + != Uri.UriSchemeHttps) + { + failures.Add( + "App:ServiceBaseUrl must use HTTPS when App:RequireHttps is true."); + } + + if (string.Equals( + options.ApiKey, + "CHANGE_ME", + StringComparison.OrdinalIgnoreCase) + || options.ApiKey.StartsWith( + "replace-", + StringComparison.OrdinalIgnoreCase)) + { + failures.Add( + "App:ApiKey still contains a placeholder value."); + } + + return failures.Count + == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail( + failures); + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.Development.json b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.Development.json new file mode 100644 index 0000000..e1641df --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "App": { + "TimeoutSeconds": 20 + }, + "Features": { + "AdvancedSearch": true + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.json b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.json new file mode 100644 index 0000000..ffec0d3 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/src/ConfigPrecedenceMinimal/appsettings.json @@ -0,0 +1,11 @@ +{ + "App": { + "ServiceBaseUrl": "https://api.example.test", + "TimeoutSeconds": 10, + "RequireHttps": true, + "ApiKey": "" + }, + "Features": { + "AdvancedSearch": false + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigPrecedenceMinimal.Tests.csproj b/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigPrecedenceMinimal.Tests.csproj new file mode 100644 index 0000000..b9ad0ae --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigPrecedenceMinimal.Tests.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + true + false + true + Exe + + + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigurationTests.cs b/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..f97fc37 --- /dev/null +++ b/dotnet-8-essentials/configuration-secrets-environments/tests/ConfigPrecedenceMinimal.Tests/ConfigurationTests.cs @@ -0,0 +1,443 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Net.Http.Json; +using ConfigPrecedenceMinimal.Diagnostics; +using ConfigPrecedenceMinimal.Options; +using ConfigPrecedenceMinimal.Validation; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ConfigPrecedenceMinimal.Tests; + +public sealed class ConfigurationTests +{ + private const string TestApiKey = + "test-api-key-never-return"; + + [Fact] + public void + Provider_order_gives_command_line_final_precedence() + { + const string variableName = + "DOTNET_GUIDE_CFGTEST_App__TimeoutSeconds"; + + Environment.SetEnvironmentVariable( + variableName, + "30"); + + try + { + var configuration = + new ConfigurationManager(); + + configuration + .AddInMemoryCollection( + new Dictionary< + string, + string?> + { + ["App:TimeoutSeconds"] = + "10" + }); + + configuration + .AddEnvironmentVariables( + "DOTNET_GUIDE_CFGTEST_"); + + configuration + .AddCommandLine( + [ + "--App:TimeoutSeconds=40" + ]); + + Assert.Equal( + "40", + configuration[ + "App:TimeoutSeconds"]); + } + finally + { + Environment + .SetEnvironmentVariable( + variableName, + null); + } + } + + [Fact] + public void + Data_annotations_reject_short_api_key() + { + var options = + new AppOptions + { + ServiceBaseUrl = + "https://api.example.test", + + TimeoutSeconds = + 10, + + RequireHttps = + true, + + ApiKey = + "short" + }; + + var context = + new ValidationContext( + options); + + List + results = + [ + ]; + + bool valid = + Validator.TryValidateObject( + options, + context, + results, + validateAllProperties: + true); + + Assert.False( + valid); + + Assert.Contains( + results, + result => + result.MemberNames + .Contains( + nameof( + AppOptions + .ApiKey))); + } + + [Fact] + public void + Custom_validator_enforces_https_when_required() + { + var validator = + new AppOptionsValidator(); + + ValidateOptionsResult result = + validator.Validate( + name: + null, + + options: + new AppOptions + { + ServiceBaseUrl = + "http://api.example.test", + + TimeoutSeconds = + 10, + + RequireHttps = + true, + + ApiKey = + TestApiKey + }); + + Assert.True( + result.Failed); + + Assert.Contains( + result.Failures, + failure => + failure.Contains( + "HTTPS", + StringComparison + .Ordinal)); + } + + [Fact] + public void + Custom_validator_rejects_placeholder_api_key() + { + var validator = + new AppOptionsValidator(); + + ValidateOptionsResult result = + validator.Validate( + name: + null, + + options: + new AppOptions + { + ServiceBaseUrl = + "https://api.example.test", + + TimeoutSeconds = + 10, + + RequireHttps = + true, + + ApiKey = + "CHANGE_ME" + }); + + Assert.True( + result.Failed); + + Assert.Contains( + result.Failures, + failure => + failure.Contains( + "placeholder", + StringComparison + .OrdinalIgnoreCase)); + } + + [Fact] + public async Task + Root_returns_resolved_values_without_secret() + { + await using var factory = + CreateFactory( + environment: + "Development", + + overrides: + new Dictionary< + string, + string?> + { + ["App:ApiKey"] = + TestApiKey, + + ["App:TimeoutSeconds"] = + "33", + + ["Features:AdvancedSearch"] = + "false" + }); + + HttpClient client = + factory.CreateClient(); + + HttpResponseMessage response = + await client.GetAsync( + "/", + TestContext.Current + .CancellationToken); + + response + .EnsureSuccessStatusCode(); + + string body = + await response.Content + .ReadAsStringAsync( + TestContext.Current + .CancellationToken); + + Assert.Contains( + "\"timeoutSeconds\":33", + body, + StringComparison.Ordinal); + + Assert.Contains( + "\"advancedSearch\":false", + body, + StringComparison.Ordinal); + + Assert.Contains( + "\"apiKeyConfigured\":true", + body, + StringComparison.Ordinal); + + Assert.DoesNotContain( + TestApiKey, + body, + StringComparison.Ordinal); + } + + [Fact] + public async Task + Development_diagnostics_is_allowlisted_and_redacted() + { + await using var factory = + CreateFactory( + environment: + "Development", + + overrides: + ValidOverrides()); + + HttpClient client = + factory.CreateClient(); + + SafeConfigSnapshot? snapshot = + await client + .GetFromJsonAsync< + SafeConfigSnapshot>( + "/dev/config", + TestContext.Current + .CancellationToken); + + Assert.NotNull( + snapshot); + + Assert.Equal( + "Development", + snapshot.Environment); + + Assert.Equal( + "[REDACTED]", + snapshot.ApiKey); + + Assert.True( + snapshot.ApiKeyConfigured); + + string raw = + await client.GetStringAsync( + "/dev/config", + TestContext.Current + .CancellationToken); + + Assert.DoesNotContain( + TestApiKey, + raw, + StringComparison.Ordinal); + + Assert.DoesNotContain( + "ConfigurationRoot", + raw, + StringComparison.Ordinal); + } + + [Fact] + public async Task + Production_does_not_register_dev_diagnostics() + { + await using var factory = + CreateFactory( + environment: + "Production", + + overrides: + ValidOverrides()); + + HttpClient client = + factory.CreateClient( + new + WebApplicationFactoryClientOptions + { + AllowAutoRedirect = + false + }); + + HttpResponseMessage response = + await client.GetAsync( + "/dev/config", + TestContext.Current + .CancellationToken); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + } + + [Fact] + public void + Missing_required_secret_fails_application_startup() + { + using var factory = + CreateFactory( + environment: + "Production", + + overrides: + new Dictionary< + string, + string?> + { + ["App:ApiKey"] = + "", + + ["App:ServiceBaseUrl"] = + "https://api.example.test", + + ["App:RequireHttps"] = + "true" + }); + + Exception exception = + Assert.ThrowsAny< + Exception>( + () => + factory.CreateClient()); + + Assert.Contains( + "ApiKey", + exception.ToString(), + StringComparison + .OrdinalIgnoreCase); + } + + private static + Dictionary + ValidOverrides() => + new() + { + ["App:ApiKey"] = + TestApiKey, + + ["App:ServiceBaseUrl"] = + "https://api.example.test", + + ["App:TimeoutSeconds"] = + "25", + + ["App:RequireHttps"] = + "true", + + ["Features:AdvancedSearch"] = + "true" + }; + + private static + ConfigFactory + CreateFactory( + string environment, + Dictionary< + string, + string?> + overrides) => + new( + environment, + overrides); + + private sealed class ConfigFactory( + string environment, + Dictionary< + string, + string?> + overrides) : + WebApplicationFactory + { + protected override void + ConfigureWebHost( + IWebHostBuilder builder) + { + builder.UseEnvironment( + environment); + + builder.ConfigureAppConfiguration( + ( + _, + configuration) => + configuration + .AddInMemoryCollection( + overrides)); + } + } +} \ No newline at end of file